Release RC PR Workflow#
.github/workflows/release-rc-pr.yml — opens a PR to the default branch
whenever a release candidate branch is created. The workflow serves two roles:
| Role | Trigger |
|---|---|
| ci-helpers-internal | on: create — auto-fires when a release/* branch is pushed to ci-helpers itself |
| Caller reusable | workflow_call — any repo can call it to get the same auto-PR behaviour |
Required setup#
The workflow needs permission to create pull requests. GitHub provides two paths:
Option A — Enable the repository setting (recommended)#
Settings → Actions → General → Workflow permissions → tick "Allow GitHub Actions to create and approve pull requests".
This lets the built-in GITHUB_TOKEN create PRs. No secrets required.
Option B — Add a GH_PAT secret#
If the repo setting above is disabled (e.g. org policy blocks it), add a
repository secret named GH_PAT containing either:
- a classic PAT with
reposcope, or - a fine-grained PAT with Pull requests: Read and write access.
The workflow uses ${{ secrets.GH_PAT || github.token }} — it prefers the
PAT when present and falls back to github.token otherwise.
Security note: store
GH_PATin the caller repo only, scoped to that repo. ci-helpers never stores a cross-repo token. The secret flows through GitHub's encryptedworkflow_callsecrets mechanism and is only visible inside the job that needs it.
Using this as a reusable workflow (caller repos)#
Add a small wrapper in your repo. The caller's on: create event provides
github.ref_name which the called workflow reads directly — no extra inputs
needed in the common case.
# .github/workflows/release-pr.yml (in your repo)
name: Open release PR
on:
create:
jobs:
open-pr:
if: ${{ github.event.ref_type == 'branch' && startsWith(github.event.ref, 'release/') }}
uses: nikolareljin/ci-helpers/.github/workflows/release-rc-pr.yml@production
secrets: inherit # passes GH_PAT if you set it; falls back to github.token
The PR targets the repository's default branch. To target a different branch, pass base_branch (any value other than main is used as given):
uses: nikolareljin/ci-helpers/.github/workflows/release-rc-pr.yml@production
with:
base_branch: master
secrets: inherit
Why secrets: inherit is safe here#
- The
GITHUB_TOKENthe called workflow uses is scoped to your repo only. It cannot access ci-helpers or any other repo. GH_PAT, if present, is a fine-grained PAT you stored in your own repo, scoped to your repo. Neither token gives ci-helpers any cross-repo access.- This is the same model all other ci-helpers
workflow_callworkflows use.
Inputs (workflow_call)#
| Input | Type | Default | Description |
|---|---|---|---|
base_branch |
string | main |
Branch the PR targets. Any value other than main is used as given. main -- also the default, so it cannot be told apart from passing nothing -- uses the repository's default branch from the event payload. |
soft_fail |
boolean | false |
When true, a known permission 403 (Actions may not open PRs, the token lacks pull-requests: write, PRs disabled) warns and the job passes. By default it fails the job. ci-helpers' own run on create (no inputs) warns, since its release script opens the PR. |
Secrets (workflow_call)#
| Secret | Required | Description |
|---|---|---|
GH_PAT |
no | PAT with pull-request write access (classic: repo scope; fine-grained: Pull requests Read+write); falls back to github.token |
Behaviour#
- Trigger (internal):
on: createevent — fires once when a branch is first pushed, not on subsequent commits to that branch. - Trigger (callers):
workflow_callfrom a caller's wrapper --on: create(filtered torelease/*branches) or any other event, e.g.on: pushtorelease/*. - Filter: only acts on branches matching
release/[v]X.Y.Z,release/[v]X.Y.Z-rcN, orrelease/[v]X.Y.Z-rc.N. - Idempotent: if a PR already exists for that head/base pair, it does nothing.
- Default branch detection:
workflow_callwith abase_branchother thanmain: that branch.- Otherwise (
base_branchismain, or ci-helpers' ownon: create):context.payload.repository.default_branchfrom the event payload (no API call needed), thenmain.
Troubleshooting#
Two failures stop this workflow from opening a PR:
401reading the repository. The workflow readsdefault_branchfrom the event payload and makes norepos.get()call, becauseactions/github-scriptunderFORCE_JAVASCRIPT_ACTIONS_TO_NODE24: truegets401for that call. A copy that callsrepos.get()fails this way.403 GitHub Actions is not permitted to create or approve pull requests. The repository setting that lets Actions open PRs is off. Enable Option A above, or provideGH_PAT. The job fails with this message and the remediations when called;soft_fail: trueturns it into a warning.
Example branches#
| Branch | Triggers? |
|---|---|
release/1.2.3 |
yes |
release/v1.2.3 |
yes |
release/1.2.3-rc1 |
yes |
release/1.2.3-rc.1 |
yes |
release/1.2.3-hotfix |
no — non-numeric suffix |
feature/1.2.3 |
no — wrong prefix |
Before every full release: refresh the action pins#
A full release (X.Y.Z, not an -rcN) must update the third-party action SHAs
first. Run it on the release branch, before cutting:
bash scripts/update_pinned_actions.sh # rewrite stale pins
bash scripts/update_pinned_actions.sh --check # must report 0 stale, 0 warnings
Why this is a release step and not left to the bots: Dependabot opens one pull
request per action and moves the SHA without touching the version comment.
Merging six of those leaves six pins reading # v1.321.0 @ 2026-09-10 while
pointing at v1.324.0. scripts/update_pinned_actions.sh and the security audit
both read those comments, so the repository ends up reporting a version it does
not ship. The plain run above reports each such pin as NEWER and moves its
comment to the tag the SHA is, with today's date; the SHA stays.
A SHA verified as a newer release tag of the same action passes ordinary work:
workflow-yaml-check.yml on push and on other pull requests,
security-weekly.yml, and auto-tag-release-push.yml on the merge of a pull
request from any other branch all run --check --allow-newer-tag. A plain
--check runs on a pull request whose head ref starts with release/, on a
release merge or any push auto-tag-release-push.yml cannot place, and in
production-branch.yml. A comment left behind fails those until the plain run
above has corrected it.
The plain run warns and leaves the line alone when it cannot tell, or when the
SHA is a newer release of another shape (# v1 at the commit of v2.0.0, with
no v2 tag). Correct that comment by hand.
Without this step, a consumer pinned to @production gets an action version
nobody recorded.
Two things to check by hand when a Dependabot pin looks wrong:
git/ref/tags/<tag>returns the tag object for an annotated tag, not the commit. Dereference it before comparing SHAs, orgithub/codeql-actionwill look like a mismatch when it is correct.- The comment format is
# vX.Y.Z @ YYYY-MM-DD, with the date of the bump, not the date of the upstream release.
A release merge fails the pin gate#
A Dependabot bump can land on main after the release pull request went green.
The merge then fails the plain --check in auto-tag-release-push.yml, so no
tag is cut. A re-run checks the same commit and fails the same way, unless the
failure was only a lookup warning, which a re-run can clear.
Recover with a new pull request from the same head ref, while tag X.Y.Z does
not exist. The release ruleset blocks deleting the branch, so reuse it:
git fetch origin
git switch release/X.Y.Z
git pull --ff-only
git merge origin/main
bash scripts/update_pinned_actions.sh # moves the comments
bash scripts/update_pinned_actions.sh --check # must report 0 stale, 0 warnings
git commit -am "Refresh action pin comments for X.Y.Z"
git push
gh pr create --base main --head release/X.Y.Z --fill
auto-tag.yml keys on the head ref of the pull request merged as the pushed
commit and refuses only a tag that exists, so this merge cuts the tag, and for a
final X.Y.Z moves production. A different branch name cuts nothing.