Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
80ba03b
Share the version pattern and the real-exec test helper
glaubersilva Oct 30, 2026
373d021
Add the release-merge action
glaubersilva Oct 30, 2026
c5d9dad
Document the release-merge action and add a caller template
glaubersilva Oct 30, 2026
7bcb01e
Refuse a published version whose tag does not contain the release branch
glaubersilva Oct 30, 2026
8a8c275
Serialize the deploys of a repository in the caller template
glaubersilva Oct 30, 2026
d2df7cd
Plain wording in the release-merge docs and the shared version helper
glaubersilva Oct 30, 2026
97c9ebf
Share the repository, tag and command helpers between the actions
glaubersilva Oct 30, 2026
fb57f6b
Rewrap the release-merge template's header comment
glaubersilva Oct 30, 2026
358ff9f
Refuse a version the release branch does not hold
glaubersilva Oct 30, 2026
65b2dcf
Rewrap the release-merge manifest's header comment
glaubersilva Oct 30, 2026
45c7cdb
Take the release branch whole in the caller template
glaubersilva Oct 30, 2026
54da660
Check the publish input before merging and name the file behind a reg…
glaubersilva Oct 30, 2026
61d800e
Publish the release at the commit the merge left on the base, not at …
glaubersilva Nov 2, 2026
dadfacc
Let develop-branch be empty, for a repository that has no develop branch
glaubersilva Nov 2, 2026
c959753
Say what the repository has to allow the token, and that the environm…
glaubersilva Nov 2, 2026
ca9f3ea
Say that an empty develop-branch is written in the caller, not passed…
glaubersilva Nov 2, 2026
6f3f198
Leave publishing the release to the caller: release-merge only merges
glaubersilva Nov 2, 2026
24b94c4
Fix the sentence on what the rules have to allow the token
glaubersilva Nov 2, 2026
afa7337
Keep every waiting deploy in the concurrency queue
glaubersilva Nov 2, 2026
2203644
Drop the production input from the merge template: the dashboard no l…
glaubersilva Nov 2, 2026
6c4b554
Back to the default concurrency queue: actionlint does not know queue…
glaubersilva Nov 2, 2026
6e64540
Drop the merge-only caller template: a deploy workflow merges and pub…
glaubersilva Nov 2, 2026
b916c25
Merge through the GitHub API instead of git in the caller's checkout
glaubersilva Nov 3, 2026
1b4d3a4
Merge the tip that was checked, not the release branch by name
glaubersilva Nov 4, 2026
eeb3d77
Refuse a release branch that moved after the run was started
glaubersilva Nov 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 203 additions & 0 deletions .github/actions/release-merge/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
# release-merge

Merges a release: the release branch into the base branch, then the base branch back into develop,
through the GitHub API. **The base branch is `main`, or `master` where that is the repository's name
for it**, set with `base-branch`:

```yaml
- uses: stellarwp/plugin-toolbox/.github/actions/release-merge@v1
id: merge
with:
branch: ${{ inputs.branch }}
version: ${{ inputs.version }}

# The merged commit, to build the release from:
- uses: actions/checkout@v6
with:
ref: ${{ steps.merge.outputs.base-sha }}

# Build the plugin here, then publish the release at ${{ steps.merge.outputs.base-sha }}.
```

Publishing the GitHub release is not this action's: the caller does it after its build, at the
commit this action leaves on the base branch (`base-sha`), so the release can carry the build and is
published only once it exists.

It is the part of a deploy that is the same for every plugin repository, on a base branch plus
`develop` model or on a base branch alone (`develop-branch: ''` skips the merge back). Building the
plugin, uploading it anywhere and announcing it are the caller's steps, after the action, on a
checkout of `base-sha`.

## What it does

In this order, stopping at the first failure:

1. Checks the inputs, and the version against the repository's tags. See
[Versions it refuses](#versions-it-refuses).
2. Checks that the release branch, the base branch and develop exist, then that every version file
the branch's `.puprc` lists holds the version and, when the version is already tagged, that the
tag contains the branch: a retry of this release goes on, a tag made from another commit is
refused.
3. Merges the release branch into the base branch as a merge commit, `Merge release/… into main`
(or `into master`).
4. Merges the **merged commit** into develop as a merge commit, `Merge main back into develop
(release/…)`. The base branch rather than the release branch, so develop descends from the
base and consecutive releases bring one version bump each instead of divergent copies of the
same generated lines. Skipped when `develop-branch` is empty, for a repository that has no
develop branch.

The merge commit on the base branch is the `base-sha` output: what the caller checks out, builds
from and tags. GitHub makes both merge commits under the identity of `token`, `github-actions[bot]`
for the default token, and signs them. The API never fast-forwards, so each release is one merge
commit in the history.

The action reads and writes no checkout: it needs none before it, and leaves whatever checkout the
job has as it was. Check out `base-sha` after it for the steps that build the release.

## Running it again

Every step is safe to run again after a late failure, which is what a retry from a release tool
does:

- A branch that already holds the other merges as nothing to merge: GitHub answers that the base
already contains the head, and nothing is written.
- A version already published counts as a retry only when its tag contains the release branch,
that is, when it was published from this branch. See [Versions it refuses](#versions-it-refuses).
The caller's publish then has nothing to do.
- A conflict resolved by hand on the branch, then pushed, is found as an already-merged branch on
the next run, which goes on to the step that had not run.

So a run that failed after merging the base, or in a later step of the caller, is rerun as it is;
nothing has to be undone first.

## When a merge conflicts

GitHub refuses the merge, nothing is written for that branch, and the step fails with the branch
named. The branches already merged stay merged: a conflict merging the base into develop leaves the
release on the base. Resolve the conflict on the branch by hand, push, and run the step again. The
API does not say which files conflict; the merge done by hand shows them.

The log carries this line, on one line:

```
MERGE_CONFLICT: merging main into develop conflicted. Resolve it on develop by hand and push,
then run this step again. Nothing was merged into develop.
```

and the `conflict` output names the branch. The [release
dashboard](https://github.com/stellarwp/nexcess-one) classifies the run from that line: it reads the
marker `MERGE_CONFLICT` at the start of an output line of the job log, every line of which the
runner prefixes with a timestamp. The marker is printed from the script, not written in the
workflow, so it is not in the step's echoed inputs and only appears when a merge did conflict.

A merge GitHub refuses for another reason (the base branch moved while the merge was being made)
fails the step without the marker; running the step again is enough.

## Versions it refuses

- **Anything but three or four numeric parts**, such as `4.17.0` or `4.17.0.1`. A pre-release such
as `4.17.0-beta.1` and a leading `v` are refused: the version becomes the tag and the title of the
published release, and a beta or an RC is not released this way. The release dashboard validates
versions with the same rule, so a version it dispatches has passed it already; release-prep
does under the stellarwp changelog versioning.
- **A version the branch does not hold.** The release branch's `.puprc` lists the files that carry the
version under `paths.versions`, with the regex that finds it in each, the same entries release-prep
bumps. The action reads every one of them on the branch and refuses when any holds a different
version, naming the file: the version becomes the tag, so it has to be the one the branch was
prepared with. A branch without a `.puprc`, or with a file where the regex finds nothing, is
refused too, since the version it holds cannot be read.
- **An empty version.** A tool that dispatches the workflow for a release with no version set sends
an empty one. When the workflow declares `version` as required, GitHub refuses the dispatch
itself; when it is optional, the action fails the run before merging.
- **A version whose tag exists without a published release.** Publishing would release whatever
commit the tag points at. Delete the tag or release another version.
- **A version already released from another commit.** When the tag exists with a published release
and does not contain the release branch, the version went out before this branch, and the branch
was never bumped past it: a branch cut from `main` right after a release still carries that
release's version. Merging it would put a second set of changes under a version that is already
out, so the action refuses, with the branches untouched. Give the release a new version and
prepare the branch again. A tag that does contain the branch is a retry of this release, and the
action goes on.

The branches are checked too: each has to exist in the repository, the base and develop have to be
two different branches (when there is a develop), and the release branch cannot be either of them.

The release branch's tip is read once, and that commit is what the version files are read at, what
a published tag is compared against, and what is merged into the base. A commit pushed to the branch
after the check is not merged by that run; run the step again for it. The branch name appears in
the merge message and in the messages, not in what the API is asked.

The tip is read when the step runs, which in a deploy that waits for an approval is later than the
run started. Pass `head`, the commit the run was started on (`github.sha` in a `workflow_dispatch`
run), and a branch whose tip is another commit is refused before anything is merged: the commit
that was built and reviewed is no longer the tip, so the deploy is started again. The template does
this; without `head`, the tip at that moment is what merges.

## Inputs

| Input | Required | Default | What it is |
|---|---|---|---|
| `branch` | yes | | The release branch to merge, e.g. `release/M26.jigglypuff`. A branch name. |
| `head` | no | | The commit the release branch has to be at, as a full hash: `github.sha`, the commit the run was started on. Empty checks nothing. See [Versions it refuses](#versions-it-refuses). |
| `version` | yes | | The version being released: three or four numeric parts. |
| `base-branch` | no | `main` | The branch the release is merged into: `main`, or `master` where that is the repository's name for it. |
| `develop-branch` | no | `develop` | The branch the base is merged back into. Empty for a repository without one: only the first merge is made. Write `develop-branch: ''` in the caller itself; a `workflow_dispatch` input left empty takes its default, so the empty value cannot be passed through one. |
| `token` | no | `github.token` | The token for the merges and the reads. Needs `contents: write`. See [The token](#the-token). |

## Outputs

| Output | What it is |
|---|---|
| `version` | The version released. |
| `branch` | The release branch merged. |
| `base-sha` | The commit on the base branch that holds the release: the merge commit, or the base's tip on a retry that found the merge done. The commit to check out, build from and publish the release at. |
| `conflict` | The branch a merge conflicted on, or empty. A conflict fails the step, so read it with `continue-on-error` or in a later `if: failure()` step. |

## One deploy at a time

Two releases prepared with the same version, deployed at the same time, would merge side by side:
the second's changes would land on the base under a version the first had just published, and its
publish would be skipped as already done. The action cannot stop that on its own, since both runs
start before either has a tag to find. The caller does, with a `concurrency` group shared by every
deploy of the repository:

```yaml
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
```

The second run then waits for the first, finds the version tagged from another branch, and is
refused before merging. A repository that also deploys from another workflow gives it the same
group. GitHub keeps one run waiting per group: a third deploy started while one is already waiting
cancels the waiting one, which is started again. (`queue: max` would keep every waiting run, but
actionlint 1.7 does not know the key and the plugin repositories lint their workflows with it.)

## The token

The job needs `contents: write`, and the token is used for the two merges and for the branch, tag,
release and file reads. It only ever reaches github-script's client: it is never handed to git,
never on a command line and never in the log. The caller's checkout, when there is one, can use
`persist-credentials: false`.

The default `github.token` is enough when the repository's rules allow what the action does: a
merge written straight to the base branch and to develop. The merges are made through the API, not
opened as pull requests, so a base branch that only accepts pull requests refuses the merge, with
any token that is not on the rule's bypass list; a rule that requires signed commits is satisfied,
since GitHub signs the merge commits it makes. GiveWP's rules are the reference shape: the branches
are protected from deletion and non-fast-forward pushes, and `github.token` writes the merges. The
human gate is not the token: the caller puts the job in an environment with required reviewers, and
the release branch reached this step after its review and QA approval. A repository whose rules
still need another actor should pass a GitHub App installation token minted in the run rather than
a personal access token. A release published with the default `github.token` triggers no `release`
workflow; one published with a personal access token or a GitHub App token does.

## How it runs

One [actions/github-script](https://github.com/actions/github-script) step runs
[`release-merge.js`](./release-merge.js): the checks and the merges, all through github-script's
Octokit. The inputs reach the script through environment variables, never interpolated into it, so
a crafted value is data rather than part of the program.

The tests, `node --test` from the repository root, run the action against a recorded client and
assert the requests it sends and the ones it does not; no git and no network.
99 changes: 99 additions & 0 deletions .github/actions/release-merge/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Merges a release: the release branch into the base branch (`main`, or `master` where that is the
# name), then the base branch back into develop. The part of a deploy that is the same for every
# plugin; what happens after the merge (building, publishing the GitHub release, wordpress.org,
# announcements) is the caller's, at the commit this one leaves on the base (`base-sha`).
#
# The checks and the merges live in release-merge.js, run by actions/github-script, so there is no
# dependency to install and nothing to build. Everything goes through the GitHub API: the branch,
# tag and release reads, the version-file reads and the merges themselves. No checkout is needed;
# the caller checks out `base-sha` afterwards to build from it.
name: Release merge
description: Merges the release branch into the base and the base back into develop

inputs:
branch:
description: >-
The release branch to merge, e.g. `release/M26.jigglypuff`. A branch name, not any git ref.
required: true
head:
description: >-
The commit the release branch has to be at, as a full hash; empty checks nothing. A deploy that
waits for an approval merges the branch's tip when the approval comes. Pass the commit the run
was started on (`github.sha`), and a branch that moved in between is refused instead of merged.
required: false
default: ''
version:
description: >-
The version being released: three or four numeric parts, such as 4.17.0 or 4.17.0.1, the rule
the release dashboard applies. It becomes the tag and the title of the GitHub release, so it has
to be the version the release branch holds: every file the branch's .puprc lists under
paths.versions is read, and a different version in any of them is refused. A pre-release such as
4.17.0-beta.1 and an empty value are refused before anything is merged, and so is a version
whose tag already exists, unless that tag's release was published from this branch, which makes
the run a retry. Publishing the release is the caller's, after this action.
required: true
base-branch:
description: 'The branch the release is merged into: `main`, or `master` where that is the name.'
required: false
default: 'main'
develop-branch:
description: >-
The branch the base is merged back into after the release. Pass an empty value for a repository
that has no develop branch: the release is merged into the base and nothing is merged back. Write
it in the caller (`develop-branch: ''`); a workflow_dispatch input left empty takes its default.
required: false
default: 'develop'
token:
Comment thread
glaubersilva marked this conversation as resolved.
Resolved
description: >-
Token the merges are made with, and the branch, tag, release and file reads. It needs
`contents: write` on the repository, so the job needs that permission too. GitHub makes the
merge commits under its identity and signs them. It only ever reaches github-script's client,
never git or the log.
required: false
default: ${{ github.token }}

outputs:
version:
description: 'The version released.'
value: ${{ steps.merge.outputs.version }}
branch:
description: 'The release branch merged.'
value: ${{ steps.merge.outputs.branch }}
base-sha:
description: >-
The commit on the base branch that holds the release: the merge commit, or the base's tip on a
retry that found the merge already done. The commit to check out, build from and publish the
release at.
value: ${{ steps.merge.outputs.base-sha }}
conflict:
description: >-
Which branch a merge conflicted on, `main` or `develop` for the defaults, or empty when the
merges went through. A conflict fails the step, so a caller reads this with `continue-on-error`
or in a later `if: failure()` step.
value: ${{ steps.merge.outputs.conflict }}

runs:
using: 'composite'
steps:
- name: Merge the release into the base and the base back into develop
id: merge
uses: actions/github-script@v9
env:
# github-script's require() resolves against the workspace of the repository being built,
# not this action's directory, so the path has to be passed in. github.action_path is the
# documented expression for it.
ACTION_PATH: ${{ github.action_path }}
# Read from process.env rather than interpolated into the script, so an input is data and
# never part of the program.
INPUT_BRANCH: ${{ inputs.branch }}
INPUT_HEAD: ${{ inputs.head }}
INPUT_VERSION: ${{ inputs.version }}
INPUT_BASE_BRANCH: ${{ inputs.base-branch }}
INPUT_DEVELOP_BRANCH: ${{ inputs.develop-branch }}
with:
# Every read and the merges go through github-script's own client, built from this token.
github-token: ${{ inputs.token }}
script: |
const { runMerge } = require(`${process.env.ACTION_PATH}/release-merge.js`)
await runMerge({ github, core, env: process.env })

Loading
Loading