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.

SymptomWhat it meansWhere to look
Release assets fail on a user machine after a green runThe publish job rebuilt instead of downloadingSteps in the publish job for a compile or package command
No run appears after git push --tagsTag glob mismatch, or a tag created by GITHUB_TOKENon.push.tags value against the tag name
Binary reports a version the release does notVersion read from a manifest rather than the tagBuild script arguments for github.ref_name
Second matrix cell fails on uploadTwo cells writing one artifact namename input on actions/upload-artifact
Publish job downloads an empty directoryArtifact name pattern matched nothingpattern 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-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).
  • 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).
  • 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-<target>, 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).
  • 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, 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, checked on 2026-08-13.

runs-on labelOSvCPURAMStorageUSD per minute
warp-ubuntu-latest-x64-8xUbuntu 24.04832 GB150GB SSD$0.016
warp-ubuntu-latest-arm64-8xUbuntu 24.04832 GB150GB SSD$0.012
warp-macos-latest-arm64-6xmacOS 15622 GB120GB SSD$0.08
warp-windows-latest-x64-8xWindows Server 2022832 GB256GB SSD$0.032
warp-ubuntu-latest-x64-2xUbuntu 24.0428 GB150GB 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.

JobLabelMinutesUSD per minuteCost
build linux-x64warp-ubuntu-latest-x64-8x12$0.016$0.192
build linux-arm64warp-ubuntu-latest-arm64-8x12$0.012$0.144
build macos-arm64warp-macos-latest-arm64-6x18$0.08$1.440
build windows-x64warp-windows-latest-x64-8x16$0.032$0.512
verifywarp-ubuntu-latest-x64-2x6$0.004$0.024
publishwarp-ubuntu-latest-x64-2x4$0.004$0.016
Total68 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.

LegGitHub-hosted runner and rateWarpBuild label and rateLeg on GitHub-hostedLeg on WarpBuild
macOS build, 18 minutesLargest macOS ARM64 runner, 5 vCPU and 14 GB, $0.102warp-macos-latest-arm64-6x, 6 vCPU and 22 GB, $0.08$1.84$1.44
Windows build, 16 minutes8-core Windows larger runner, 8 vCPU and 32 GB, $0.042warp-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.