Skip to content

release-app.yml

Create releases using release-please, optionally tag major/minor versions, and support automerge of generated PRs.

References: googleapis/release-please-action · googleapis/release-please

Inputs

InputTypeDescriptionRequiredDefault
ENABLE_PRERELEASEbooleanEnable prerelease functionalityNofalse
TAG_MAJOR_AND_MINORbooleanTag major and minor versionsNofalse
AUTOMERGE_PRERELEASEbooleanAutomatically merge the prerelease PRNofalse
AUTOMERGE_RELEASEbooleanAutomatically merge the release PRNofalse
AUTOMERGE_METHODstringHow the PR is merged when automerge is enabled: auto (queue until required checks pass, needs Allow auto-merge on the repo) or admin (merge now, bypassing branch protection)Noauto
RELEASE_PR_AUTHORstringOptional hardening: only act on release pull requests opened by this login — see Which pull requests are eligibleNo""
PRERELEASE_BRANCHstringBranch to create the prerelease onNodevelop
RELEASE_BRANCHstringBranch to create the release onNomain
RELEASE_CONFIG_FILEstringRelease-please config file for release branchNorelease-please-config.json
RELEASE_MANIFEST_FILEstringRelease-please manifest file for release branchNo.release-please-manifest.json
PRERELEASE_CONFIG_FILEstringRelease-please config file for prerelease branchNorelease-please-config-rc.json
PRERELEASE_MANIFEST_FILEstringRelease-please manifest file for prerelease branchNo.release-please-manifest-rc.json
RELEASE_ASSET_PATHSstringComma-separated list of local file paths to upload as release assets (e.g., dist/app-linux-amd64,dist/app-darwin-amd64)No-
RELEASE_ARTIFACT_NAMESstringArtifact name or glob pattern matching one or more artifacts (uploaded by previous jobs via actions/upload-artifact) to download and attach to the release (e.g., my-binaries or my-app-*)No-
PUBLISH_DRAFT_RELEASEbooleanPublish the GitHub Release once the assets have been attached. Enable together with "draft": true and "force-tag-creation": true in the release-please config to stay compatible with immutable releasesNofalse
RUNS_ONstringRunner labels as JSON array (e.g., '["ubuntu-24.04"]' or '["self-hosted", "linux"]')No["ubuntu-24.04"]

Secrets

SecretDescriptionRequiredDefault
APP_CLIENT_IDGitHub App Client ID (not the numeric App ID). With APP_PRIVATE_KEY, authenticates as a GitHub App — takes precedence over GH_PAT. See AuthenticationNo-
APP_PRIVATE_KEYGitHub App private key (PEM). Required alongside APP_CLIENT_IDNo-
GH_PATGitHub Personal Access Token. Legacy alternative, still supported (see Token setup)No-

Supplying App credentials makes the release pull request trigger pull_request workflows, so its CI actually runs — GITHUB_TOKEN cannot. It also makes the release tag and GitHub Release created by release-please fire push: tags: and release: triggers, which GITHUB_TOKEN never did. Check what your pipeline triggers on before switching. See Loop safety.

APP_CLIENT_ID and APP_PRIVATE_KEY must be supplied together. Setting only one fails the job rather than falling back to GH_PAT or GITHUB_TOKEN.

Automerge is gated on the AUTOMERGE_* inputs, not on credentials. Adding App credentials never enables merging by itself. If automerge is enabled and no credential is supplied, the job fails rather than silently skipping.

Outputs

OutputDescription
release-createdWhether a release was created in this run
versionFull semver value (e.g., 1.2.3)
major-tagMajor version tag (e.g., 1)
minor-tagMinor version tag (e.g., 2)
patch-tagPatch version tag (e.g., 3)

Permissions

ScopeAccessDescription
contentswriteCreate tags/commits and update manifest files
issueswriteCreate or update issues opened by release tooling
pull-requestswriteCreate, update, and optionally merge release PRs

Token setup

A credential is only required when AUTOMERGE_PRERELEASE or AUTOMERGE_RELEASE is enabled. Use either a GitHub App (preferred) or a Personal Access Token, stored as repository secrets in the repository that runs this workflow.

Set APP_CLIENT_ID and APP_PRIVATE_KEY. Beyond automerge this also makes the release pull request trigger pull_request workflows, which GITHUB_TOKEN cannot — see Authentication for the full setup.

Required App repository permissions: Contents: Read & Write, Pull requests: Read & Write, Issues: Read & Write, Metadata: Read.

Which pull requests are eligible

A head branch name is chosen by whoever opened the pull request — on a fork PR it is just a branch inside their own repository — so it identifies a pull request but authorizes nothing. Three filters decide what this workflow will amend or merge:

  1. Fork pull requests are rejected outright. Without this, any GitHub user could open a PR from a branch named release-please--branches--main and have it merged into your release branch — under AUTOMERGE_METHOD: admin, past branch protection and every required check.
  2. The branch must match exactly, either release-please--branches--<base> or release-please--branches--<base>--components--<name> — the only two shapes release-please produces. A prefix match alone also accepted release-please--branches--main-anything.
  3. The pull request author must match, which closes the remaining case the fork check cannot: someone with push access crafting a release-please-shaped branch in your own repository.

All three are on by default and need no configuration. The author is derived from whichever credential opened the pull request:

CredentialAuthor the workflow expectsDerived from
GitHub Appapp/<app-slug>the token action's own app-slug output
GITHUB_TOKENapp/github-actionsfixed — this is always the author
GH_PAT(check skipped)the author is the token owner, which cannot be derived

Note the app/ prefix. That is how the API reports a bot author — not github-actions[bot]. Setting RELEASE_PR_AUTHOR: github-actions[bot] would match nothing and silently stop every merge.

Set RELEASE_PR_AUTHOR only to override the derivation — pin your PAT owner's login under GH_PAT, or pass * to disable the author check while keeping the fork and branch guards.

Can an outsider forge the author? No. author.login is set by GitHub from the identity that opened the pull request; it is not attacker-supplied. A fork's GITHUB_TOKEN is an installation token scoped to that fork, so it cannot open a pull request in your repository at all — and the fork check rejects cross-repository pull requests before the author is even examined. The two guards are independent, and the fork check fails closed: a pull request whose fork status cannot be determined is refused rather than allowed.

What remains. Someone who already has push access could add a workflow that has your own github-actions bot open a pull request on a release-please-shaped branch. That passes all three filters. RELEASE_PR_AUTHOR does not help — they would match it. This is the trust boundary of push access rather than something the workflow can close: anyone who can push a workflow can already run arbitrary code in CI. If that matters for your repository, require review on .github/workflows/** via CODEOWNERS, and prefer AUTOMERGE_METHOD: auto so branch protection still applies.

How the merge happens

The automerge step uses gh pr merge --rebase, with the method chosen by AUTOMERGE_METHOD:

  • auto (default) queues the PR and lets GitHub merge it once all required status checks pass. This requires Settings > General > Allow auto-merge to be enabled on the repository.
  • admin force-merges immediately, bypassing branch protection and required status checks.

There is no automatic fallback between them. If auto is selected and auto-merge is not enabled on the repository, the job fails with a message naming the setting to enable. This is deliberate: falling back to --admin would merge past the very checks App authentication makes run.

The step merges every open release-please pull request targeting the current branch, so monorepos using separate-pull-requests (one pull request per component) are handled — not just the first one found.

Fine-grained PAT

Create a fine-grained personal access token scoped to the current repository with the following permissions:

PermissionAccessReason
ContentsRead & WritePush commits (manifest sync, rebase branch)
Pull requestsRead & WriteEnable auto-merge on release PRs

With AUTOMERGE_METHOD: admin the PAT owner must be a repository admin for the force-merge to succeed.

Classic PAT

Alternatively, create a classic token with the repo scope.

Where to store it

Add the credential as repository secrets (APP_CLIENT_ID + APP_PRIVATE_KEY, or GH_PAT):
Settings > Secrets and variables > Actions > New repository secret

If both are set, the App takes precedence — useful for verifying the switch before removing the PAT.

Notes

  • Set ENABLE_PRERELEASE: false to disable all prerelease functionality and work only with release branches.
  • Config and manifest files are configurable via inputs, with sensible defaults for both release and prerelease workflows.
  • On RELEASE_BRANCH (default main), uses the files specified by RELEASE_CONFIG_FILE and RELEASE_MANIFEST_FILE.
  • On PRERELEASE_BRANCH (default develop), uses the files specified by PRERELEASE_CONFIG_FILE and PRERELEASE_MANIFEST_FILE (only when ENABLE_PRERELEASE: true).
  • If TAG_MAJOR_AND_MINOR: true, tags v<major> and v<major>.<minor> after a release is created.
  • If AUTOMERGE_* is enabled and a PAT is provided, attempts to automerge the release PR.
  • Asserts that PRERELEASE_BRANCH holds everything published on RELEASE_BRANCH, before any version is computed — see Prerelease sync assertion. The rebase itself belongs to sync-prerelease-branch.yml.
  • RELEASE_ASSET_PATHS uploads files that are already present on the runner filesystem. RELEASE_ARTIFACT_NAMES accepts a name or glob pattern — the matching artifacts are downloaded via actions/download-artifact before being attached to the release; both inputs can be used together.
  • PUBLISH_DRAFT_RELEASE: true publishes the release after the assets have been attached. It is a no-op when the release is not a draft, so the step is safe to re-run. See immutable releases.

Prerelease sync assertion

On a PRERELEASE_BRANCH run (and only with ENABLE_PRERELEASE: true), the workflow checks before computing any version that the prerelease branch holds everything the release branch published. Nothing to configure: both branch names are already inputs.

This is the invariant every version computed here depends on: the prerelease branch is the release branch plus only the unreleased work. When it holds, release-please and any chart bump start from the released state. When it does not, they start from wherever the branch was frozen, and emit versions below the ones already published — silently.

What keeps the invariant true is a job the caller schedules (sync-prerelease-branch.yml), and no workflow can verify it was wired up. The assertion therefore catches every way it can break: job missing, incomplete needs:, a failed sync, a force-push, or a pipeline shape nobody anticipated.

On failure the run stops with the number of missing commits and what to do. Two possible causes:

  • The sync job is missing, or its needs: does not cover the whole pipeline. This is the case to fix.
  • A release pipeline is still running on the release branch. The default concurrency group is keyed on the branch, so a prerelease run can start during a release: re-running once it completes is enough.

The assertion uses a GitHub API compare call rather than git merge-base --is-ancestor: ancestry needs real history, and actions/checkout leaves the clone shallow — the git form would have to unshallow the repository on every prerelease run.

A repository that has not created RELEASE_BRANCH yet has nothing to compare against: the assertion does nothing rather than blocking its first prereleases.

Configuration

Release-please requires a config file and a manifest file in the repository root. The manifest tracks the current version and is updated automatically on each release.

Release config (release-please-config.json)

Minimal config for a single-package repository using the node release type (adjusts package.json version). See the release-please docs for all available options and release types.

json
{
  "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
  "packages": {
    ".": {
      "release-type": "node",
      "initial-version": "0.0.1",
      "include-component-in-tag": false,
      "versioning": "prerelease",
      "prerelease": false,
      "prerelease-type": "",
      "extra-files": []
    }
  }
}

Release manifest (.release-please-manifest.json)

Tracks the current version for each package path. Release-please updates this file automatically — set the initial version to your current release.

json
{
  ".": "0.0.1"
}

Prerelease config (release-please-config-rc.json)

Used when ENABLE_PRERELEASE: true. Identical structure to the release config but adds prerelease-type to control the prerelease identifier appended to the version.

json
{
  "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
  "release-type": "node",
  "prerelease-type": "rc",
  "packages": {
    ".": {
      "release-type": "node",
      "initial-version": "0.0.1",
      "include-component-in-tag": false,
      "versioning": "prerelease",
      "prerelease": true,
      "prerelease-type": "rc",
      "extra-files": []
    }
  }
}

Prerelease manifest (.release-please-manifest-rc.json)

json
{
  ".": "0.0.1"
}

Immutable releases

Immutable releases freeze a GitHub Release the moment it is published: assets can no longer be added, changed or removed, and the associated tag can no longer be moved or deleted. The only ordering GitHub supports is therefore create as a draft → attach the assets → publish.

This only matters if you attach assets, through RELEASE_ASSET_PATHS or RELEASE_ARTIFACT_NAMES. Without assets the workflow is already compatible and there is nothing to change.

Three changes are needed, two of them in your own release-please config:

jsonc
{
  "packages": {
    ".": {
      // Create the release as a draft, so assets can still be attached to it.
      "draft": true,
      // GitHub does not create the git tag until a draft is published, and
      // release-please needs the tag to resolve the previous release.
      "force-tag-creation": true
    }
  }
}
yaml
jobs:
  release:
    uses: this-is-tobi/github-workflows/.github/workflows/release-app.yml@v0
    permissions:
      contents: write
      issues: write
      pull-requests: write
    with:
      RELEASE_ARTIFACT_NAMES: my-app-binaries
      PUBLISH_DRAFT_RELEASE: true

Worth knowing:

  • PUBLISH_DRAFT_RELEASE is an explicit opt-in. release-please's draft option is also the documented way to hold a release back for manual publication, so this workflow never publishes a draft unless asked to.
  • The step is idempotent. An already-published release is left untouched, so the step is safe to re-run.
  • release: events fire later. A draft fires nothing; release: published/released comes from the publish step, once the assets are attached. Check the triggers of any workflow that reacts to your releases.
  • A mid-run failure leaves a draft, which you can publish with gh release edit <tag> --draft=false or by re-running. That is exactly what this setup buys you: without it, on a repository with immutable releases, a failed asset upload leaves a published, incomplete release that is unrecoverable — the tag name stays burned even after deleting the release.
  • The floating v<major>/v<major>.<minor> tags from TAG_MAJOR_AND_MINOR are unaffected. Immutability locks only tags carrying a published release, and release-please only ever creates releases on v<major>.<minor>.<patch>.

WARNING

release-helm.yml with CREATE_GITHUB_RELEASE: true is not compatible with immutable releases: chart-releaser creates the release and then attaches the chart .tgz in two separate API calls, with no draft option (helm/chart-releaser#591). Publishing charts to an OCI registry instead (PUBLISH_OCI: true, CREATE_GITHUB_RELEASE: false) creates no GitHub Release and is unaffected.

Examples

The examples cover the main release scenarios: a full setup with prerelease support, a release-only flow, and a build that attaches compiled binaries to the GitHub Release.

They use GitHub App credentials, the recommended mode. To use a personal access token instead, replace the two APP_* lines with GH_PAT: ${{ secrets.GH_PAT }} — nothing else changes. Both can be passed together during a migration; the App wins. See Authentication for end-to-end setup of either.

Simple example

Full two-branch setup with develop for prereleases and main for stable releases. AUTOMERGE_*: true requires a PAT with sufficient permissions to bypass branch protection rules. Keeping develop rebased onto main after each stable release is sync-prerelease-branch.yml's job.

yaml
jobs:
  release:
    uses: this-is-tobi/github-workflows/.github/workflows/release-app.yml@v0
    permissions:
      contents: write
      issues: write
      pull-requests: write
    with:
      ENABLE_PRERELEASE: true
      TAG_MAJOR_AND_MINOR: true
      AUTOMERGE_PRERELEASE: true
      AUTOMERGE_RELEASE: true
      # Optional: customize config and manifest files
      RELEASE_CONFIG_FILE: custom-release-config.json
      PRERELEASE_CONFIG_FILE: custom-prerelease-config.json
    secrets:
      APP_CLIENT_ID: ${{ secrets.APP_CLIENT_ID }}
      APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}

Release-only workflow

Single-branch workflow targeting only main. No prerelease config files are required. TAG_MAJOR_AND_MINOR: true adds convenience aliases (v1, v1.2) to each stable release tag.

yaml
jobs:
  release:
    uses: this-is-tobi/github-workflows/.github/workflows/release-app.yml@v0
    permissions:
      contents: write
      issues: write
      pull-requests: write
    with:
      ENABLE_PRERELEASE: false
      TAG_MAJOR_AND_MINOR: true
      AUTOMERGE_RELEASE: true
    secrets:
      APP_CLIENT_ID: ${{ secrets.APP_CLIENT_ID }}
      APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}

Attach build artifacts to the release

Two options are available depending on where the artifacts live.

Option 1 — files on the runner filesystem (RELEASE_ASSET_PATHS): use this when the files are produced in the same job (or already present on the runner).

yaml
jobs:
  release:
    uses: this-is-tobi/github-workflows/.github/workflows/release-app.yml@v0
    permissions:
      contents: write
      issues: write
      pull-requests: write
    with:
      ENABLE_PRERELEASE: false
      RELEASE_ASSET_PATHS: "dist/my-app-linux-amd64,dist/my-app-darwin-amd64"
    secrets:
      APP_CLIENT_ID: ${{ secrets.APP_CLIENT_ID }}
      APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}

Option 2 — artifacts from a previous job (RELEASE_ARTIFACT_NAMES): accepts a name or glob pattern — matching artifacts uploaded via actions/upload-artifact in the same run are downloaded automatically before being attached to the release.

yaml
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v6
    - name: Build
      run: make build
    - uses: actions/upload-artifact@v7
      with:
        name: my-app-binaries
        path: dist/

  release:
    needs: build
    uses: this-is-tobi/github-workflows/.github/workflows/release-app.yml@v0
    permissions:
      contents: write
      issues: write
      pull-requests: write
    with:
      ENABLE_PRERELEASE: false
      RELEASE_ARTIFACT_NAMES: "my-app-binaries"  # or a glob like "my-app-*"
    secrets:
      APP_CLIENT_ID: ${{ secrets.APP_CLIENT_ID }}
      APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}

Both inputs can be combined when some files are local and others come from previous jobs.