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: string matters here. GitHub Actions also supports choice, boolean, and environment types. For versions, string is the right call since you can’t enumerate every possible RC version ahead of time.
  • The required: true only 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: true marks 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_commitish points the tag at the release branch, not main. The tag goes on the right branch.
  • generate_release_notes: true auto-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.