Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ef939b9
Share the version pattern and the real-exec test helper
glaubersilva Oct 30, 2026
84d5961
Add the release-merge action
glaubersilva Oct 30, 2026
1027c4a
Document the release-merge action and add a caller template
glaubersilva Oct 30, 2026
8bce125
Refuse a published version whose tag does not contain the release branch
glaubersilva Oct 30, 2026
a74b151
Serialize the deploys of a repository in the caller template
glaubersilva Oct 30, 2026
f64b62d
Plain wording in the release-merge docs and the shared version helper
glaubersilva Oct 30, 2026
23b6a71
Share the repository, tag and command helpers between the actions
glaubersilva Oct 30, 2026
2e083ba
Rewrap the release-merge template's header comment
glaubersilva Oct 30, 2026
2e84685
Refuse a version the release branch does not hold
glaubersilva Oct 30, 2026
4779f4c
Rewrap the release-merge manifest's header comment
glaubersilva Oct 30, 2026
d2cf03a
Take the release branch whole in the caller template
glaubersilva Oct 30, 2026
b066fe6
Check the publish input before merging and name the file behind a reg…
glaubersilva Oct 30, 2026
7ed3388
Publish the release at the commit the merge left on the base, not at …
glaubersilva Nov 2, 2026
50b9cf8
Let develop-branch be empty, for a repository that has no develop branch
glaubersilva Nov 2, 2026
6ade5dc
Say what the repository has to allow the token, and that the environm…
glaubersilva Nov 2, 2026
ff87d5d
Say that an empty develop-branch is written in the caller, not passed…
glaubersilva Nov 2, 2026
073aae1
Leave publishing the release to the caller: release-merge only merges
glaubersilva Nov 2, 2026
0891a30
Fix the sentence on what the rules have to allow the token
glaubersilva Nov 2, 2026
b11e46a
Keep every waiting deploy in the concurrency queue
glaubersilva Nov 2, 2026
f45a2cf
Drop the production input from the merge template: the dashboard no l…
glaubersilva Nov 2, 2026
f81f0cf
Back to the default concurrency queue: actionlint does not know queue…
glaubersilva Nov 2, 2026
f145579
Drop the merge-only caller template: a deploy workflow merges and pub…
glaubersilva Nov 2, 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
193 changes: 193 additions & 0 deletions .github/actions/release-merge/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
# release-merge

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

```yaml
- uses: actions/checkout@v6
with:
persist-credentials: false

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

# 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 the
checkout it leaves on the merged base branch.

## 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. Fetches the release branch, the base branch and develop from `origin`, with the full history
when the checkout is shallow. Then it checks 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`), and pushes it.
4. Merges the **base branch** into develop as a merge commit, `Merge main back into develop
(release/…)`, and pushes it. 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 checkout is left on the base branch at the merged commit, clean, and that commit is the
`base-sha` output: what the caller builds from and tags. The merge commits are
made by `github-actions[bot]`; the pushes are made by the identity of `token`, also
`github-actions[bot]` for the default token.

## 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 "Already up to date" and pushes nothing.
- 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

The conflicted merge is abandoned (`git merge --abort`), nothing is pushed for that branch, and the
step fails with the branch named. The branches already pushed stay pushed: 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 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 pushed to 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. The
two lines git itself prints, `CONFLICT (…)` and `Automatic merge failed`, are in the log as well.

## 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 be a name git accepts, the base and develop have to be
two different branches (when there is a develop), and the release branch cannot be either of them.

## Inputs

| Input | Required | Default | What it is |
|---|---|---|---|
| `branch` | yes | | The release branch to merge, e.g. `release/M26.jigglypuff`. A branch name, fetched from `origin`. |
| `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 fetch, the pushes and the tag 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 publish the release at; the checkout is left on it. |
| `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 fetch, the two pushes and the tag
and release reads. It is handed to git through the environment of those commands only (`GIT_CONFIG_*`, the
`http.<server>/.extraheader` header actions/checkout would store), so it is never in the checkout's
`.git/config` and never on a command line in the log. Use `persist-credentials: false` on the
checkout; it matters when later steps of the same job run third-party actions.

The default `github.token` is enough when the repository's rules allow what the action does: a
direct push to the base branch and to develop. The merges are pushed, not opened as pull requests,
so a base branch that only accepts pull requests refuses the push, with any token that is not on
the rule's bypass list. GiveWP's rules are the reference shape: the branches are protected from
deletion and non-fast-forward pushes, and `github.token` pushes 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.

## The checkout

The action runs in the caller's checkout, which can be on any branch and at any depth: the three
branches are fetched explicitly, and a shallow checkout is unshallowed first. Run it on a clean
checkout; it switches branches, and uncommitted changes would travel with it or stop it.

The checkout's `origin` has to be the repository the run is on: `GITHUB_REPOSITORY` is where the
tags and the release are read and written.

## 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. 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 merges are git commands in the checkout; the tag and release reads go
through github-script's Octokit.

The tests, `node --test` from the repository root, run the merges with real git against a bare
repository on disk and the API calls against a recorded client.
92 changes: 92 additions & 0 deletions .github/actions/release-merge/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# 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. The merges are git in the caller's checkout; the tag
# and release reads go through the GitHub API.
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. It is
fetched from `origin`; the caller's checkout can be on any branch and at any depth.
required: true
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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One token does both the pushes and the release. On repos with a tag ruleset, github.token can't create the tag, so the caller has to pass a bot token. If that bot can also bypass the "only PR pushes" rule on main, the action could push a merge nobody reviewed. Could the push token and the release token be separate inputs? Or could there be a merge: false mode for repos where the release PR is merged by a person and the action just checks the version and publishes?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right on both counts. I kept one token per action and wrote in each README what the repository has to allow it, instead of adding inputs. The reasoning:

The token is the job's own GITHUB_TOKEN, scoped by permissions: contents: write and gone when the run ends. On GiveWP it is enough: the master ruleset blocks deletion and non-fast-forward pushes, there is no tag ruleset, and a release published with it does not trigger any workflow that runs on the release event.

TEC needs tec-bot because its tag ruleset blocks tag creation, and GITHUB_TOKEN cannot be on a bypass list. A bot PAT that bypasses every rule is a larger key than the problem it solves. If the ruleset blocked deletion and updates but not creation, github.token would publish there too, with the environment's required reviewers as the gate on who creates tags.

On "a merge nobody reviewed": the action is built for the NX1 release dashboard, which is the only thing that dispatches the deploy workflow. In the dashboard a release goes through three steps per repository, and the deploy is the last one: first release-prep bumps the version and writes the changelog on the release branch and a reviewer approves that branch (on its pull request, or in the dashboard); then the RC zip is built and a QA person tests and approves it; only then can someone click "Release Now", which dispatches the workflow that runs release-merge. So by the time the action pushes the merge, the branch has been reviewed and the build tested, and the person who approves the live environment authorizes that specific run. That gate does not depend on which token pushes. Separate push and release tokens would only matter on a repository that trusts the token to rewrite main but not to create a tag, so I did not add them.

merge: false is running release-publish on its own (#16), with the branch as target, which is what your "Release: Publish" workflow does today. If TEC keeps the ruleset, a GitHub App installation token minted in the run (actions/create-github-app-token) passed as token is the safer form of the bypass; the README says it does trigger workflows on the release event. Does that cover the TEC case?

description: >-
Token the fetch and the pushes are made with, and the tag and release reads. It needs
`contents: write` on the repository, so the job needs that permission too. It is handed to git
through the environment of the fetch and the pushes only, never written to the checkout's
config, so the checkout can use `persist-credentials: false`.
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 publish the release at; it is also the
commit the checkout is left on.
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_VERSION: ${{ inputs.version }}
INPUT_BASE_BRANCH: ${{ inputs.base-branch }}
INPUT_DEVELOP_BRANCH: ${{ inputs.develop-branch }}
INPUT_TOKEN: ${{ inputs.token }}
with:
# The tag and release reads 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, exec, env: process.env })

Loading
Loading