GitHub Actions: Building a Beta Release Workflow That Actually Works
I kept running into the same problem at work: someone would trigger a beta release workflow and forget to add the -rc suffix to the version string. The pipeline would happily churn through every step, create a release branch, tag it, push it out — and now we had a “beta” release that looked exactly like a production one. No easy way to tell them apart. Rolling it back meant manual git surgery.
So I built a workflow that validates inputs before doing anything else, manages release branches automatically, and tags everything correctly. Here’s the full thing, and I’ll walk through each piece.
The Full Workflow
name: Beta Release
# Use this workflow to create a brand new release branch and rc tag.
on:
workflow_dispatch:
inputs:
version:
description: "The version to make the new beta release (must include '-rc' suffix)."
type: string
required: true
permissions:
actions: write
contents: write
jobs:
beta-release:
runs-on: stable-prod
steps:
- name: Validate RC suffix
run: |
if [[ ! "${{ inputs.version }}" =~ -rc ]]; then
echo "Error: The version must include the '-rc' suffix"
echo "Provided version: ${{ inputs.version }}"
exit 1
fi
- name: Checkout Repo
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744
with:
fetch-depth: 0
- name: Manage Release Branches
shell: bash
run: |
# Your release branch creation/management script goes here
python scripts/release-manager/release.py ${{ inputs.version }}
- name: Get Release Branch
id: release-branch
run: |
VER=$(echo "${{ inputs.version }}" | cut -d '.' -f1-2)
echo "name=release/${VER}" >> $GITHUB_OUTPUT
- name: Checkout Release Branch
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
with:
ref: ${{ steps.release-branch.outputs.name }}
fetch-depth: 0
- name: Update Release Branch From Main
shell: bash
run: |
git merge --ff-only origin/main
git push origin ${{ steps.release-branch.outputs.name }}
- name: Release
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844
with:
prerelease: true
tag_name: ${{ inputs.version }}
target_commitish: ${{ steps.release-branch.outputs.name }}
generate_release_notes: true
token: ${{ secrets.GITHUB_TOKEN }}
- name: Announce Version
run: echo "::notice::Released beta version ${{ inputs.version }}!"
That’s the complete workflow. Now let me break down the parts that matter.
Input Validation: Fail Fast, Fail Loud
The first step is the most important one, and it’s just bash:
- name: Validate RC suffix
run: |
if [[ ! "${{ inputs.version }}" =~ -rc ]]; then
echo "Error: The version must include the '-rc' suffix"
echo "Provided version: ${{ inputs.version }}"
exit 1
fi
The =~ operator does regex matching in bash. The ! negates it — so this reads as “if the version does NOT contain -rc, bail out.” The exit 1 kills the entire workflow immediately.
This is a pattern I use constantly now. Any workflow_dispatch input that has a specific format requirement gets a validation step before anything else runs. Version strings, environment names, branch prefixes — validate them all up front. The worst thing a CI pipeline can do is silently proceed with bad input and leave you cleaning up the mess afterward.
A couple of things worth noting about workflow_dispatch inputs:
- The
type: stringmatters here. GitHub Actions also supportschoice,boolean, andenvironmenttypes. For versions,stringis the right call since you can’t enumerate every possible RC version ahead of time. - The
required: trueonly checks that the field isn’t empty in the UI. It does not validate format — that’s on you.
Release Branch Strategy
The branch management logic extracts the major.minor version and creates a release/X.Y branch:
- name: Get Release Branch
id: release-branch
run: |
VER=$(echo "${{ inputs.version }}" | cut -d '.' -f1-2)
echo "name=release/${VER}" >> $GITHUB_OUTPUT
If someone triggers the workflow with version 2.5.0-rc1, this step produces release/2.5. That branch becomes the home for all release candidates in that minor version series: 2.5.0-rc1, 2.5.0-rc2, 2.5.1-rc1, and so on.
The $GITHUB_OUTPUT syntax is how you pass data between steps. The old ::set-output command was deprecated — if you see that in existing workflows, it needs updating.
After checking out the release branch, we fast-forward merge from main:
- name: Update Release Branch From Main
shell: bash
run: |
git merge --ff-only origin/main
git push origin ${{ steps.release-branch.outputs.name }}
The --ff-only flag is deliberate. If the release branch has diverged from main (maybe someone pushed a hotfix directly to it), the merge will fail instead of creating a merge commit. This keeps the history clean and forces you to deal with divergence explicitly rather than hiding it behind a merge commit.
Tagging and the GitHub Release
The softprops/action-gh-release action handles creating the actual GitHub Release:
- name: Release
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844
with:
prerelease: true
tag_name: ${{ inputs.version }}
target_commitish: ${{ steps.release-branch.outputs.name }}
generate_release_notes: true
token: ${{ secrets.GITHUB_TOKEN }}
A few things happening here:
prerelease: truemarks this as a pre-release in GitHub. This matters because tools and scripts that look for the “latest” release will skip pre-releases by default.target_commitishpoints the tag at the release branch, not main. The tag goes on the right branch.generate_release_notes: trueauto-generates release notes from merged PRs and commits since the last release. Not perfect, but good enough for RC releases where you just need a quick summary of what changed.
Pinning Action Versions
You probably noticed the actions are pinned to full commit SHAs instead of version tags:
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744
This is a security practice. Version tags like v4 are mutable — someone who compromises the action’s repository can move the tag to point at malicious code. A commit SHA is immutable. If you’re running workflows that have contents: write permissions (like this one does), pinning to SHAs is worth the extra effort. Tools like Dependabot and Renovate can keep these updated automatically.
Permissions: Least Privilege
permissions:
actions: write
contents: write
Explicitly declaring permissions at the workflow level is good practice. Without this block, GitHub Actions defaults to giving the GITHUB_TOKEN broad permissions. By declaring only what you need — actions: write for workflow management and contents: write for pushing branches and creating releases — you limit the blast radius if something goes wrong.
Tips From Running This in Production
Use workflow_dispatch for anything that needs human judgment. Automated triggers are great for CI, but release workflows benefit from a human deciding “yes, this is ready for a beta.” The GitHub UI gives you a nice form with your defined inputs, and you get a clear audit trail of who triggered what.
Pass secrets explicitly. Even though GITHUB_TOKEN is available automatically, being explicit about token: ${{ secrets.GITHUB_TOKEN }} in the release step makes it clear what credentials are being used. For workflows that need custom PATs or deploy keys, this pattern keeps things readable.
The ::notice:: annotation at the end is small but useful. It shows up as a highlighted message in the workflow summary. When someone checks the Actions tab to see if the release went through, they get a clear confirmation with the exact version that was released.
Consider adding a dry-run mode. You can add a boolean input like dry_run and wrap the release step in a conditional: if: inputs.dry_run != true. Handy when you want to test the branch management logic without actually creating a release.
Wrapping Up
The core idea here is straightforward: validate early, branch predictably, tag accurately. The bash regex check for -rc is maybe 5 lines of code, but it’s saved me from cleaning up botched releases more times than I’d like to admit. GitHub Actions’ workflow_dispatch gives you just enough structure to build reliable manual workflows without overengineering things.
If you’re managing beta releases and your current process involves “just remember to add the RC suffix,” do yourself a favor and let the pipeline enforce it instead.