Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 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
e6e2d2f
fix(release-prep): validate the tag of the latest pup release
glaubersilva Oct 16, 2026
37e9a2e
fix(release-prep): fail loudly on a phar-less release instead of pre-…
glaubersilva Oct 16, 2026
bc202ae
chore(release-prep): rename the template's dispatch input from ref to…
glaubersilva Oct 16, 2026
f8efcd9
docs(release-prep): remove evaluative filler from the README and the …
glaubersilva Oct 20, 2026
5743a70
docs(release-prep): say which pup releases ship a phar and what happe…
glaubersilva Oct 20, 2026
0ba8319
fix(release-prep): expose the distribution output and tell a missing …
glaubersilva Oct 20, 2026
3e7fcf5
feat(release-prep): validate the version against the configured chang…
glaubersilva Oct 20, 2026
bb8fc3a
refactor(release-prep): commit through the REST API instead of pushing
glaubersilva Oct 20, 2026
2e11f84
docs(release-prep): leave how the TypeScript line is called open, and…
glaubersilva Oct 20, 2026
4d59537
docs(release-prep): a custom strategy is a .js file
glaubersilva Oct 21, 2026
d2018e4
docs(release-prep): wrap the how-it-runs paragraph
glaubersilva Oct 21, 2026
b96265a
fix(release-prep): upload the staged blob, not the file on disk
glaubersilva Oct 21, 2026
d476136
fix(release-prep): read blobs of any size into the commit
glaubersilva Oct 21, 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
239 changes: 239 additions & 0 deletions .github/actions/release-prep/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
# release-prep

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

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

- uses: stellarwp/plugin-toolbox/.github/actions/release-prep@v1
with:
branch: ${{ inputs.branch }}
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 to `branch` as `Prepare <branch> (<version>)`,
through the GitHub API, by the identity of `token`: `github-actions[bot]` for the default token. 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. See [Committing](#committing).

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.

Three cases 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 — and the action validates the version as semver.
- A custom versioning strategy, `versioning` set to a `.js`: the action does not
check the version's shape. See [Versions it refuses](#versions-it-refuses).

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 is checked against the versioning strategy the repository's changelogger configures,
`changelogger.versioning` in `package.json`, so the action refuses what the changelog step would
refuse, before pup has touched anything:

- `stellarwp`: 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` is refused, even though it is a valid tag name.
- `semver`, or no `changelogger` section: `MAJOR.MINOR.PATCH` with an optional pre-release, such as
`4.17.0` or `4.17.0-beta.1`. A fourth part, `4.17.0.1`, is refused. So is a leading `v`: the
changelogger's semver check would take `v4.17.0`, but the version goes into the version files
and the tag as it is given.
- A custom strategy, `versioning` set to a `.js` or `.ts` file: no shape check. The changelog step
validates the version with that strategy, and the action warns that it did not.
- Any other `versioning` value fails the action, because the changelog step would fail on it.

The changelog step validates the version only when `changelog/` holds entries to write; without
any, it writes nothing and accepts any version. The action's check does not depend on that.

- **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 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. Only the numeric start of the
version is compared: `4.17.0-beta.1` counts as `4.17.0`, so it is accepted on a branch at `4.17.0`
and refused on one at `4.18.0`. When the version files hold something that is not a numeric
version, such as `dev`, or the version itself does not start with a number, 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 commit to, e.g. `release/M26.jigglypuff`. A branch name, not `refs/heads/…`. The checkout has to be on it; see [Committing](#committing) |
| `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 created with. See [Committing](#committing) |
| `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 created on the branch. Empty when nothing changed |
| `files` | `my-plugin.php` | The files the commit changed, one per line. Empty when nothing changed |
| `distribution` | `phar` | Which pup distribution the release carries. `phar` on every run that succeeds, the only distribution the action runs today |

## 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 whether the action can run it, not a version comparison: the action
sends a HEAD request to the release's `pup.phar` asset. Every release from 1.2.4 to 2.0.0
publishes one; the phar downloads into the runner's temp directory and runs on the runner's PHP.
A release without one is not supported yet: the prepare step fails, saying so and naming a pin
to use instead. A pinned release that does not exist, such as a mistyped number, fails the step
with "is not a release"; the action tells the two apart with a second HEAD request, to the
release's page.

The TypeScript line of pup publishes no phar. How the action will call it is open until that
line ships a distribution: through pup's own GitHub Action, or straight from the script with the
arguments the phar takes. The manifest cannot carry the first behind a condition today, because a
composite action's `uses:` references resolve when the job is set up, not when the step runs, so
a reference to an action that does not exist yet breaks every run; the second needs something
published to call. Either way the change is local to the script, and the action's inputs and
outputs stay as they are. 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 nothing, calls no API, 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.

## Committing

The job needs `contents: write`:

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

The commit is created through the GitHub API with `token`, not pushed with git. The action lists
the changes with git (`git add --all`, then `git diff --cached --raw`), uploads each changed file
as a blob, builds a tree on the checkout's own tree, creates a commit whose parent is the
checkout's own head, and points `branch` at it. Nothing is pushed, so the checkout can use
`persist-credentials: false`, and no token reaches git or the log. GitHub signs commits created
this way, so they show as verified.

The ref update is not forced. If the branch moved after the checkout, GitHub refuses the update
as it would refuse a push, the job fails saying so, and the files of the checkout are never
written over newer ones.

The checkout itself is left as it was: its HEAD stays on the commit it had, with the prepared
files in the working tree. A later step that reads the branch with git sees the commit before the
preparation; the `commit` output holds the new one.

The commit goes to the repository the workflow runs in (`GITHUB_REPOSITORY`). Only regular files
are uploaded; a changed symlink or submodule fails the step, naming the path.

The checkout has to be on `branch` itself, and the action checks that before changing anything.
Committing from another branch would carry that branch's commits into the release whenever the
release branch is an ancestor of it — for instance a run started from `main` whose checkout was
given no `ref`. 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 commit. GitHub creates no workflow run for
a commit 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, the `.puprc` and the
changelogger strategy, then runs pup. The second commits through the API. github-script supplies
the Node runtime, `exec` and the `github` client, built from `token`; 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 step runs real git against a local checkout to list the
changes, and sends the commit to an Octokit whose network is a stand-in that records every
request. Run `npm install` once, then `node --test`.
114 changes: 114 additions & 0 deletions .github/actions/release-prep/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Prepares a release branch: bumps the version, replaces the TBD placeholders, writes the changelog,
# then commits that to the branch through the GitHub API. It opens no pull request, so the change
# is reviewed wherever the release itself is reviewed.
#
# The checks and the commit 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 commits it to the release branch

inputs:
branch:
description: >-
The release branch to commit 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. It is validated against the versioning strategy the repository's
changelogger configures: three or four numeric parts for `stellarwp`, a semver version for
`semver` or no configuration, no shape check for a custom strategy. 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 created with, through the GitHub API. It needs `contents: write` on the
repository, so the job needs that permission too. Nothing is pushed with git, 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. It has to ship a pup.phar asset, which every
release from 1.2.4 to 2.0.0 does. A release without one is not supported yet: the action
fails and names a pin to use 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 created on the branch. Empty when nothing changed and nothing was committed.'
value: ${{ steps.commit.outputs.commit }}
files:
description: 'The files the commit changed, one per line. Empty when nothing changed.'
value: ${{ steps.commit.outputs.files }}
distribution:
description: >-
Which pup distribution the release carries. On a run that succeeds it is `phar`, the only
distribution the action runs today.
value: ${{ steps.prepare.outputs.distribution }}

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 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 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 }}
with:
# github-script builds its `github` client from its own token input, which defaults to
# github.token: the action's token has to be handed over here or it is never used.
github-token: ${{ inputs.token }}
script: |
const { runCommit } = require(`${process.env.ACTION_PATH}/release-prep.js`)
await runCommit({ github, core, exec, env: process.env })
Loading
Loading