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

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. Artifact storage counts against the shared storage allowance on your plan and bills at $0.25 per GB-month beyond it, 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 and how long artifacts are kept cover the settings hierarchy; the arithmetic is in the cost model below.

Fix

Match the destination to how long the bytes have to stay reachable, then set the retention window to your release cadence.

PayloadWho consumes itWhere it belongsRetention setting
Compiled bundle, test fixtures, packaged binariesLater jobs in the same runArtifact, uploaded onceDays, matched to rollback window
Test reports, coverage files, failure logsA human reading the run summaryArtifact7 to 14 days
Container imageDeploy jobs and clustersContainer registry, pushed from the build jobRegistry lifecycle policy
Signed installers and release binariesEnd users, auditorsRelease asset or registryOutlives any artifact window
Runner-local state such as warm daemonsThe next run, not this oneCache or snapshot runnerCache key expiry

Then set retention-days on every upload. The default of 90 days is a value nobody chose.

Release cadenceretention-daysWhat the window has to cover
Deploy on every merge to main3Rollback to any of the last few merges
Weekly release train14The shipped release plus a week of hotfix candidates
Monthly release45Two releases in flight at once
Regulated hold on signed buildsNot an artifactPush 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 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, and the deploy side of the pattern is in build once, deploy many.

Configuration

One producer, five consumers, nothing rebuilt:

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

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, checked on 2026-08-13, at the same vCPU and RAM shape.

StepRunnerWarpBuild per minuteGitHub-hosted per minuteMinutes per run
Upload on the build jobwarp-ubuntu-latest-x64-4x (4 vCPU, 16 GB)$0.008$0.0121.0
Download on 4 e2e shardswarp-ubuntu-latest-x64-8x (8 vCPU, 32 GB)$0.016$0.0222.4
Download on the deploy jobwarp-ubuntu-latest-x64-2x (2 vCPU, 8 GB)$0.004$0.0060.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:

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 optionRate90-day defaultretention-days: 3
GitHub Actions artifact, past a 50 GB Enterprise Cloud allowance$0.25 per GB-month3,168 GB, $779.50144 GB, $23.50
Amazon ECR image tag, layers stored once$0.10 per GB-month397 GB, $39.7119 GB, $1.91

The 50 GB figure is the Actions artifact storage allowance on GitHub Enterprise Cloud, 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, 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 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.

FAQ

How do I publish a build artifact so later GitHub Actions jobs can use it?

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?

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?

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.

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.