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 credential exchange and a digest pull, and a time model for a deploy job whose minutes are dominated by artifact transfer.

Diagnosis

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 doesWhat it meansWhere the cost lands
Runs docker build or a compile step after the gateThe approver released a commit, and the job produced a fresh artifact from itBuild minutes billed behind an approval, and a binary nobody tested
Pulls by tag rather than by digestA moving tag can resolve to different bytes between the test job and the deploy jobSilent drift between environments
Holds a long-lived cloud access key in a repository secretThe credential outlives the run and every job that can read the secret can use itBlast radius, and a rotation task nobody owns
Shares a concurrency group with the checks workflowGitHub resolves group names across the repository, so a test run and a release occupy one laneA release parked behind a test run
Sets cancel-in-progress: true on the deploy laneThe next merge stops an apply where it standsA 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 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.

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

Bind the job to an environment

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

Give the deploy lane its own concurrency group

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

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

Pull the artifact by digest

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

Rates and shapes below come from the cloud runners documentation and the pricing page, checked on 2026-08-13.

runs-on labelOSvCPURAMStorageRate per minute
warp-ubuntu-latest-x64-2xUbuntu 24.0428 GB150GB SSD$0.004
warp-ubuntu-latest-x64-4xUbuntu 24.04416 GB150GB SSD$0.008
warp-ubuntu-latest-x64-8xUbuntu 24.04832 GB150GB SSD$0.016
warp-ubuntu-latest-x64-16xUbuntu 24.041664 GB150GB 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.

Configuration

The build job pushes once and exports the digest. The deploy job consumes it, and does nothing else that produces bytes.

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:

  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

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

InputValueSource
Merges to main per month120Your workflow run history
Deploy targets per merge3 (staging, canary, production)The pipeline above, extended
Container image pulled per deploy1.6 GBYour registry metrics
Artifact bundle downloaded per deploy0.7 GBYour artifact retention view
Approval wait on production35 minutesYour deployments view
Runner rates$0.004 to $0.016 per minutepricing page, checked 2026-08-13

Where a single deploy spends its minutes

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.

Step2x (2 vCPU)4x (4 vCPU)8x (8 vCPU)
Waiting for the environment gate35.0 min, not dispatched35.0 min, not dispatched35.0 min, not dispatched
Sparse checkout and OIDC exchange0.4 min0.4 min0.4 min
Pull image by digest, 1.6 GB2.0 min1.4 min1.3 min
Download artifact bundle, 0.7 GB0.9 min0.6 min0.6 min
Apply4.6 min3.4 min2.9 min
Smoke test1.8 min1.2 min1.0 min
Billed minutes9.77.06.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

LabelBilled minutes per monthMonthly costWall clock per deploy
warp-ubuntu-latest-x64-2x3,492$13.979.7 min
warp-ubuntu-latest-x64-4x2,520$20.167.0 min
warp-ubuntu-latest-x64-8x2,232$35.716.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.

Every rate above carries a source link and a checked-on date, and the step timings are assumptions to replace with your own.

FAQ

What belongs in a deployment job and what belongs upstream?

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?

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, checked on 2026-08-13).

What size runner does a deployment job need?

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.

Start with $10 in free credits

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