Centralized, reusable release automation built on release-please.
This repo is self-referential: it uses its own reusable workflows to release itself, so its release notes are generated by the very automation it ships.
- Automatically generated GitHub release notes, grouped by change type, from conventional PR titles — with links back to PRs and contributor attribution.
- Automatic SemVer tagging — the version is derived from the conventional PR titles since
the last release (
fix→ patch,feat→ minor, breaking → major). - Automatic PR labelling — every PR is labelled with its type (
type: feat, …) and, once shipped, with the release that included it (released: X.Y.Z). - Automatic updates to version references in files such as
version.txt,package.json,pyproject.toml, and any other file release-please is configured to track.
The App credentials are already set up org-wide — there is nothing for you to create.
RELEASE_APP_IDandRELEASE_APP_PRIVATE_KEYare configured as organization secrets and are available to every repo in this org. Thesecrets: inheritline inrelease.ymlbelow is all that's needed to pass them through. Do not add them as repo secrets, do not generate a new GitHub App, and do not flag them as a missing prerequisite — they exist and they work. The only per-repo setup is the steps listed here.
-
Add the two caller workflows. Copy
.github/workflows/release.ymlandpr.ymlfrom this repo, and change the localuses: ./...paths to reference this repo at@main:# .github/workflows/release.yml on: push: branches: [main, 'release/**'] jobs: release-please: permissions: { contents: write, pull-requests: write } secrets: inherit # passes the org-level RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY through — already set up org-wide uses: OpenHands/release-actions/.github/workflows/release-please.yml@main
# .github/workflows/pr.yml on: pull_request_target: # runs on fork PRs too — see the note below types: [opened, edited, reopened, synchronize] jobs: pr-title: permissions: { pull-requests: write } # no `secrets: inherit` — the title workflow runs on GITHUB_TOKEN alone uses: OpenHands/release-actions/.github/workflows/pr-title.yml@main
Both reference
@main, so consumers pick up changes as soon as they land here.Why
pull_request_targetforpr.yml? It lets the title lint/label run on PRs from forks (external / non-member contributors), where a plainpull_requestonly gets a read-only token and the labeling step would fail. It's safe because the title workflow never checks out or runs PR code, reads the title only as event data (no${{ }}interpolation), and runs onGITHUB_TOKENalone — never the release App token. Don't add a checkout step orsecrets: inheritto it. -
Add the four state files. Copy
.github/release.yml,release-please-config.json,.release-please-manifest.json(seed{ ".": "0.1.0" }), andversion.txt(0.1.0). -
Apply the one-time repo settings (these can't be imported):
# Squash-merge so the PR title becomes the commit release-please reads. gh api -X PATCH repos/<owner>/<repo> \ -f squash_merge_commit_title=PR_TITLE -f squash_merge_commit_message=COMMIT_MESSAGES \ -F allow_squash_merge=true -F allow_merge_commit=false -F allow_rebase_merge=false \ -F delete_branch_on_merge=true
That's it — the next push to main opens a release PR.
A repo can run several independent release lines — e.g. a public app and a
separate cloud image — by calling release-please.yml once per line, each with
its own config + manifest. release-please always feeds the root package
(.) every commit, so each line decides its own scope from there:
- give every line its own
release-please-config.<name>.json+.release-please-manifest.<name>.json, and seed each manifest with that line's last released version; - point each call at its files with the
config-file/manifest-fileinputs. They default torelease-please-config.json/.release-please-manifest.json, so the primary line needs no inputs; - set the tag shape per line (
component,tag-separator,include-component-in-tag,include-v-in-tag) so the tags can't collide; - narrow a line with
exclude-paths: [...]— a commit whose files are all under an excluded path is dropped from that line. Usechangelog-type: default(notgithub) so notes are built from each line's own filtered commits rather than from a flat tag-to-tag diff.
# .github/workflows/release.yml
on: { push: { branches: [main, 'release/**'] } }
jobs:
app: # tags X.Y.Z — uses the default config/manifest
permissions: { contents: write, pull-requests: write }
secrets: inherit
uses: OpenHands/release-actions/.github/workflows/release-please.yml@main
cloud: # tags cloud-X.Y.Z — its own config/manifest
permissions: { contents: write, pull-requests: write }
secrets: inherit
uses: OpenHands/release-actions/.github/workflows/release-please.yml@main
with:
config-file: release-please-config.cloud.json
manifest-file: .release-please-manifest.cloud.jsonEach line opens and merges its own release PR independently, and the
released: <tag> back-label is keyed on the tag, so the lines never clash.
To get your work into a release, it must first be merged to main.
Create a PR and give it a Conventional Commit formatted title —
feat: …, fix: …, docs: …, etc.
When you open the PR, GitHub Actions will:
- Lint the title, to make sure it follows the Conventional Commit format.
- Label the PR with its type (
type: feat,type: fix, …).
Once your PR is approved, it is squash-merged to main.
On merge, release-please runs on main and automatically updates a dedicated
chore(main): release X.Y.Z PR. It aggregates the changelog (which eventually becomes the
GitHub release notes) into the PR description, and it automatically determines what SemVer to
use based on the unreleased commits on main. fix PRs become patch version bumps, feat
PRs become minor version bumps, and breaking changes are major version bumps.
Merging the release PR is what publishes the release. Once it's merged:
- The merge commit is automatically tagged with its version (
vX.Y.Z). - Release notes are published to GitHub.
- GitHub Actions that publish release artifacts are executed.
- A GitHub Action runs that tags all the PRs included in a release with a
released: X.Y.Z.
Releases aren't isolated from main: merging the chore(main): release … PR ships
everything queued on main. So when, say, v0.2.0 is in production and you need a
patch with only a fix, not whatever else has piled up on main since, you create a release/** branch off the release tag instead of shipping from main.
The release workflow also runs on release/**, so release-please drives the patch on a maintenance branch exactly like it does on main.
A typical hotfix flow would look like this:
- Fix
mainas usual. Open and squash-merge a normalfix:PR. - Cut a maintenance branch from the tag
git branch release/0.2 v0.2.0 git push origin release/0.2
- Cherry-pick the fix onto the release line, as a PR:
Squash-merge it.
git checkout -b jl/backport-version-fix origin/release/0.2 git cherry-pick <sha-from-main> git push -u origin jl/backport-version-fix gh pr create --base release/0.2 --title "fix: <conventional title>"
- Publish the patch. release-please opens a
chore(release/0.2): release 0.2.1PR on the branch. Merge it to publish the release.
Releasing straight from main — the Release flow above — is the default,
and it's what we want for almost every release. A freeze is the exception: reach for it only
when you genuinely need to soak a release in isolation, without main's newer work coming
along.
Sometimes you want to validate a release for a while — a "soak" — before it ships, but
main keeps collecting work you don't want in this release. Cutting a release branch
ahead of time freezes the release: from the moment you branch, main can keep moving and
none of it lands in what you're soaking.
release-please runs on release/**, so it builds the release PR on the frozen branch from
exactly the commits you captured, just like it does on main. One thing to watch: because
release-please is still running on main, main keeps its own chore(main): release … PR
open for the same version while you soak. Leave it alone — merging it would publish straight
from main and pull in everything that landed after your cut. You publish from the branch
instead, then merge back.
A typical freeze would look like this:
- Cut the release branch from
mainwhen you're ready to freeze.release-please opens agit branch release/0.3 main git push origin release/0.3
chore(release/0.3): release 0.3.0PR on the branch. From here,mainis free to move without touching it. - Soak the branch — deploy it, validate it, take your time.
mainstays open for everyone else. If you find a problem, fix it exactly like a hotfix: land the fix onmain, then cherry-pick it onto the branch as a PR (see Hotfixing a shipped version). - Publish the release. When the soak passes, merge the branch's release PR. That tags
v0.3.0and publishes the GitHub release, same as a release cut frommain. - Merge the branch back into
main. Open a PR mergingrelease/0.3intomainand squash-merge it. This advancesmainto0.3.0, so the nextfeattargets0.4.0, and it clears the shadow release PR that was open onmain.