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:
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 hub.
Diagnosis
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 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
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). 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).
The version comes from somewhere other than the tag
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 created by actions/upload-artifact v4 are immutable, and artifact names must be unique because multiple jobs cannot modify one artifact (upload-artifact documentation). 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), 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
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.
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 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).
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).
Configuration
This workflow builds four targets on a v tag, verifies the collected artifacts once, and publishes them from a single job.
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-notesWhat each piece does:
on.push.tagsruns the workflow only for tags matching the glob.github.ref_nameholds the tag name for that event and is the single version source for the build script (contexts reference).- The matrix uses
includeentries 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: bashkeeps one build script across Ubuntu, macOS, and Windows, since Windows runners default to PowerShell (workflow syntax).fail-fast: truecancels the remaining targets when one fails. A partial release has no use, and cancelling saves the minutes.- Each cell uploads to
dist-<target>, so the four names are distinct. The verify and publish jobs collect them withpatternandmerge-multiple: true, which unpacks every match into one directory (storing and sharing data). permissionsis read at the workflow level andcontents: writeonly on the publish job, so the build and verify jobs cannot create a release.gh release createtakes the tag and a file list, and--generate-notesbuilds the notes from merged pull requests (gh release create, automatically generated release notes).- No job after
buildruns 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, 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 | 22 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
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, 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 22 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 22 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 8 GB more RAM. 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 that catches the same failures before the candidate exists.
Full rates by runner type are on the WarpBuild pricing page. For the credential side of the publish job, see how to publish a package from GitHub Actions.
FAQ
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.
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.