Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
184b01c
Add an exec stand-in and a job summary reader to the test harness
glaubersilva Oct 8, 2026
4b21bed
Leave the test runner's own reports out of the captured log
glaubersilva Oct 8, 2026
5b35c2a
Add the release-prep logic: version, date and .puprc checks, pup, com…
glaubersilva Oct 8, 2026
496f901
Add the release-prep action manifest and its README
glaubersilva Oct 8, 2026
6d0e310
Add a release-prep caller to copy, and tick release-prep off the plan
glaubersilva Oct 8, 2026
652b39c
Clear a credential the checkout persisted before pushing with the token
glaubersilva Oct 8, 2026
2b6a48c
Say that GitHub refuses an empty required version before the action runs
glaubersilva Oct 8, 2026
e835e28
Refuse a version lower than the one the branch already has
glaubersilva Oct 9, 2026
ce2ef0e
Say what happens when the branch's version is missing or not numeric
glaubersilva Oct 9, 2026
5122413
refactor: replace release name samples
glaubersilva Oct 12, 2026
9a41799
docs(release-prep): state the warning behavior without the evaluative…
glaubersilva Oct 15, 2026
e7c13da
docs(release-prep): scope the version rule to the default changelogge…
glaubersilva Oct 15, 2026
936eed4
refactor(release-prep): rename the branch input from ref to branch
glaubersilva Oct 15, 2026
4087f53
docs(release-prep): attribute the version shape to the default change…
glaubersilva Oct 15, 2026
0214ed1
feat(release-prep): resolve the latest pup release instead of a hardc…
glaubersilva Oct 15, 2026
5232e2c
refactor: share compareVersions between the release-prep and resolve-…
glaubersilva Oct 15, 2026
4aae955
feat(release-prep): run whichever distribution a pup release publishes
glaubersilva Oct 15, 2026
4f31db8
docs(release-prep): rewrite the README without the generative filler
glaubersilva Oct 15, 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
219 changes: 219 additions & 0 deletions .github/actions/release-prep/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
# release-prep

Prepares a release branch: bumps the version, replaces the `@since TBD` placeholders, writes the
changelog, then commits and pushes the result to the branch:

```yaml
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
persist-credentials: false

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

The action opens no pull request; the commit is reviewed on the release branch's own pull request.
[`templates/workflows/release-prep.yml`](../../../templates/workflows/release-prep.yml) is a complete
workflow around it, ready to copy into a plugin repository.

## What it changes

Three tools run, in this order, from the root of the checkout:

1. `pup replace-version <version>` rewrites the version in every file `.puprc` lists under
`paths.versions`.
2. `pup replace-tbd <version>` replaces the TBD placeholders (`@since TBD`, `@deprecated TBD`,
`_deprecated_function( __METHOD__, 'TBD' )` and the like) in the directories `.puprc` lists under
`checks.tbd.dirs`.
3. The [changelogger action](https://github.com/stellarwp/changelogger) writes the pending entries in
`changelog/` into the changelog files and deletes them, with the version and the date.

Everything the working tree changed is then committed as `Prepare <branch> (<version>)` by
`github-actions[bot]` and pushed to `branch`. Run the action on a clean checkout, and put any step
that writes tracked files after it: the commit includes every change in the working tree.

pup is downloaded from its GitHub release into the runner's temp directory, so it never ends up in
the commit. See [Which pup runs](#which-pup-runs).

## What the repository needs

A `.puprc` listing the version files and the directories that hold the TBD placeholders:

```json
{
"paths": {
"versions": [
{ "file": "my-plugin.php", "regex": "(Version: )(.+)" },
{ "file": "my-plugin.php", "regex": "(define\\( 'MY_PLUGIN_VERSION', ')([^']+)" },
{ "file": "readme.txt", "regex": "(Stable tag: )(.+)" }
]
},
"checks": {
"tbd": {
"dirs": ["src", "includes"]
}
}
}
```

A `changelogger` section in `package.json`, and the pending entries in its `changesDir`:

```json
{
"changelogger": {
"versioning": "stellarwp",
"changesDir": "changelog",
"files": [{ "path": "readme.txt", "strategy": "stellarwp-readme" }]
}
}
```

The action checks the `.puprc` before running anything, because pup does not: a missing file, one
that is not JSON, or one with no `paths.versions` makes pup change nothing and exit 0. The job
fails on any of the three, with the branch untouched.

Two gaps only produce a warning; the run continues:

- No `checks.tbd.dirs`: pup falls back to its default and replaces TBDs under `src/` only.
- No `changelogger` section: the changelogger uses its defaults — a keepachangelog `changelog.md`
with semver versioning.

A writing strategy given as a `.js` file is loaded from the checkout; it has to be there before the
action runs. One written in TypeScript has to be built in an earlier step.

## Versions it refuses

The version has to be three or four numeric parts, such as `4.17.0` or `4.17.0.1`. That is the shape
the changelogger's default `stellarwp` versioning accepts. A repository with a custom changelogger
version strategy may accept other shapes, but this action still takes three or four numeric parts.
The changelog step would fail on anything else, so the action refuses it before pup has touched
anything.

- **An empty version is refused.** A tool that dispatches the workflow for a release with no version
set sends an empty one. When the workflow declares `version` as required, as the template does,
GitHub refuses the dispatch itself with HTTP 422 and no run starts. When the workflow declares it
optional, the run starts and the action fails it with "No version was given". Either way the
release needs a version before the step runs again.
- **A pre-release is refused.** `4.17.0-beta.1` is rejected by the `stellarwp` versioning, so the
action cannot prepare one, even though it is a valid tag name. A custom version strategy that
accepts pre-releases does not change this.
- **A version lower than the branch's is refused.** pup reads the version the branch already has
(`pup get-version`, the first file in `paths.versions`), and preparing `4.17.0` on a branch at
`4.18.0` would bump the plugin backwards: either the version or the branch is wrong. The same
version is accepted; that is what re-running a preparation finds. When the version files hold
something that is not a numeric version, such as `dev`, the job only warns. When pup cannot find a
version at all, it fails, as `replace-version` would.

Each refusal happens before anything is changed. So do a `date` that is not `YYYY-MM-DD`, a `branch`
that is not a valid branch name, and a checkout that is not on `branch`.

## Inputs

| Input | Required | Default | What it does |
|---|---|---|---|
| `branch` | yes | | The release branch to push to, e.g. `release/M26.jigglypuff`. A branch name, not `refs/heads/…`. The checkout has to be on it; see [Pushing](#pushing) |
| `version` | yes | | The version to prepare, e.g. `4.17.0`. See [Versions it refuses](#versions-it-refuses) |
| `date` | no | today, in UTC | The changelog date as `YYYY-MM-DD`. Empty or `today` writes today |
| `token` | no | `github.token` | The token the commit is pushed with. See [Pushing](#pushing) |
| `pup-version` | no | `latest` | The pup release to run: a number such as `2.0.0`, or `latest` for the newest. See [Which pup runs](#which-pup-runs) |

## Outputs

| Output | Example | What it is |
|---|---|---|
| `version` | `4.17.0` | The version prepared |
| `date` | `2026-10-08` | The date written in the changelog |
| `commit` | `3f2a…` | The commit pushed to the branch. Empty when nothing changed |
| `files` | `my-plugin.php` | The files the commit changed, one per line. Empty when nothing changed |

## Which pup runs

`pup-version` pins a pup release (`2.0.0`) or tracks the newest one (`latest`, the default). A
pinned release downloads straight from its release page. `latest` reads the newest release through
the GitHub API, authenticated with `token` so the runner's shared unauthenticated quota is not
spent.

The release itself decides which kind of pup runs, not a version comparison: the action sends a
HEAD request to the release's `pup.phar` asset. Releases up to and including 2.0.0 publish one; the
phar downloads into the runner's temp directory and runs on the runner's PHP. A release that
answers 404 is the TypeScript line, which runs through its own GitHub Action — the same way the
changelog step calls the changelogger action. While every release carries a phar, that path never
runs, and callers change nothing when it starts to.

The manifest carries the call already, as the contract the pup action is expected to ship:

```yaml
- uses: stellarwp/pup@v3
with:
command: replace-version --version=4.17.0
```

`uses:` takes a static ref, so the floating major tag is the moving pin. The branch-version check
— the refusal of a version lower than the branch already has — runs on the phar path only; the TS
contract does not define it yet.

## Running it again

Preparing a version the branch already holds changes nothing: the versions match, no TBDs are left
and `changelog/` is empty. The action commits and pushes nothing and succeeds, so a retry of a
finished preparation is safe.

## The date

The changelogger action writes the date exactly as it is given: `today` would land in the changelog
as the word, and an empty value as an empty date. The action resolves both to today's date, in UTC,
before the changelog step runs, and that is the date the `date` output reports.

## Pushing

The job needs `contents: write`:

```yaml
jobs:
prepare:
permissions:
contents: write
```

The action pushes with `token` itself, to `HEAD:refs/heads/<branch>`, so the checkout can use
`persist-credentials: false`. The token reaches git through the environment of that one push —
never on its command line, which the log would echo, and never in `.git/config`, where a later
step could read it. The token and the base64 credential built from it are masked in the log.

When the checkout keeps its own credentials, git would send two Authorization headers — its own
and the action's — and GitHub refuses the push with a 400. The action clears the
`http.<server>/.extraheader` key before adding its own header, so only `token` is sent.

The push is a fast-forward. If the branch moved after the checkout, the push is refused and the
job fails with git's output.

The checkout has to be on `branch` itself, and the action checks that before changing anything.
Pushing from another branch would also fast-forward whenever the release branch is an ancestor of
it — for instance a run started from `main` whose checkout was given no `ref` — and the release
would pick up every commit of that branch. A detached checkout is refused for the same reason.

Pass a PAT or app token as `token` when a ruleset on the release branches blocks `github.token`,
or when a workflow in the repository has to run on the push. GitHub creates no workflow run for a
push made with `GITHUB_TOKEN`.

## How it runs

Two [actions/github-script](https://github.com/actions/github-script) steps run `release-prep.js`,
with the changelogger action between them. The first validates the inputs and the `.puprc`, then
runs pup. The second commits and pushes. github-script supplies the Node runtime and `exec`; there
is no dependency to install and no bundle to build.

The script is loaded by path rather than inlined in the manifest because github-script's `require`
resolves against the workspace of the repository being built, not this action's directory. The
path comes from `${{ github.action_path }}`.

Inputs reach the script through `process.env`, never by being interpolated into it, so a version
or a branch name is data and cannot become part of the program. The commands run with their
arguments as a list, without a shell.

`tests/actions/release-prep/release-prep.test.js` covers the rules above. The pup steps run
against a stand-in for `exec`; the commit and push run real git against a local repository. Run
`npm install` once, then `node --test`.
123 changes: 123 additions & 0 deletions .github/actions/release-prep/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Prepares a release branch: bumps the version, replaces the TBD placeholders, writes the changelog,
# then commits that and pushes it to the branch. It opens no pull request, so the change is reviewed
# wherever the release itself is reviewed.
#
# The checks and the git work live in release-prep.js, run by actions/github-script, so there is no
# dependency to install and nothing to build. The changelog is written by the changelogger's own
# action between the two script steps, rather than reimplemented here.
name: Release prep
description: Bumps the version, replaces TBDs and writes the changelog, then pushes it to the release branch

inputs:
branch:
description: >-
The release branch to push the preparation to, e.g. `release/M26.jigglypuff`. A branch name, not
any git ref. The action does not check it out: the caller's checkout has to be on it, and the
action refuses to run when it is not.
required: true
version:
description: >-
The version to prepare: three or four numeric parts, such as `4.17.0` or `4.17.0.1`. That is
the shape the default `stellarwp` changelogger versioning takes; a custom version strategy
does not widen what the action accepts. An empty value and a version lower than the one the
branch already has are refused before anything is changed.
required: true
date:
description: >-
The release date for the changelog, as `YYYY-MM-DD`. Empty or `today` writes today's date in
UTC.
required: false
default: ''
token:
description: >-
Token the commit is pushed with. It needs `contents: write` on the repository, so the job needs
that permission too. The action pushes with it explicitly, so the checkout can use
`persist-credentials: false`.
required: false
default: ${{ github.token }}
pup-version:
description: >-
The pup release that bumps the version and replaces the TBDs: a release number, such as
2.0.0, or 'latest' for the newest release. Versions up to 2.0.0 ship as a .phar; once pup
publishes a release without one, the action runs that release's own GitHub Action instead.
required: false
default: 'latest'

outputs:
version:
description: 'The version prepared.'
value: ${{ steps.prepare.outputs.version }}
date:
description: 'The date written in the changelog, as `YYYY-MM-DD`.'
value: ${{ steps.prepare.outputs.date }}
commit:
description: 'The commit pushed to the branch. Empty when nothing changed and nothing was pushed.'
value: ${{ steps.commit.outputs.commit }}
files:
description: 'The files the commit changed, one per line. Empty when nothing changed.'
value: ${{ steps.commit.outputs.files }}

runs:
using: 'composite'
steps:
- name: Check the inputs, bump the version and replace the TBDs
id: prepare
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_DATE: ${{ inputs.date }}
INPUT_PUP_VERSION: ${{ inputs.pup-version }}
# Authenticates the 'latest' lookup against the GitHub API; a pinned pup-version
# never calls the API.
INPUT_TOKEN: ${{ inputs.token }}
with:
script: |
const { runPrepare } = require(`${process.env.ACTION_PATH}/release-prep.js`)
await runPrepare({ core, exec, env: process.env })

# The TypeScript line of pup publishes no .phar; its release runs through its own GitHub
# Action, called here the way the changelog step calls the changelogger. These steps run
# only when the prepare step resolved a release without a phar asset, so they stay inert
# until such a release exists — and spell out the interface the pup action is expected to
# ship. uses: takes a static ref, so the floating major tag is the moving pin.
- name: Bump the version (pup)
if: ${{ steps.prepare.outputs.distribution == 'ts' }}
uses: stellarwp/pup@v3
with:
command: replace-version --version=${{ steps.prepare.outputs.version }}

- name: Replace the TBDs (pup)
if: ${{ steps.prepare.outputs.distribution == 'ts' }}
uses: stellarwp/pup@v3
with:
command: replace-tbd --version=${{ steps.prepare.outputs.version }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# The resolved date, never inputs.date: this action writes the date it is given as it is, so
# `today` would land in the changelog as the word and an empty value as an empty date.
- name: Write the changelog
uses: stellarwp/changelogger@v1
with:
command: write
version: ${{ steps.prepare.outputs.version }}
date: ${{ steps.prepare.outputs.date }}

- name: Commit the preparation and push it to the release branch
id: commit
uses: actions/github-script@v9
env:
ACTION_PATH: ${{ github.action_path }}
# The values the prepare step validated, not the raw inputs.
INPUT_BRANCH: ${{ steps.prepare.outputs.branch }}
INPUT_VERSION: ${{ steps.prepare.outputs.version }}
INPUT_TOKEN: ${{ inputs.token }}
with:
script: |
const { runCommit } = require(`${process.env.ACTION_PATH}/release-prep.js`)
await runCommit({ core, exec, env: process.env })
Loading
Loading