Skip to content
Open
37 changes: 23 additions & 14 deletions .github/actions/release-merge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,26 @@ for it**, set with `base-branch`:
with:
ref: ${{ steps.merge.outputs.base-sha }}

# Build the plugin here, then publish the release at ${{ steps.merge.outputs.base-sha }}.
# Build the plugin here, then publish the release at the merged commit:
- uses: stellarwp/plugin-toolbox/.github/actions/release-publish@v1
with:
version: ${{ inputs.version }}
target: ${{ 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.
Publishing the GitHub release is [release-publish](../release-publish/README.md)'s, run after this
action and after the caller's 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. GiveWP's
deploy workflow is that chain: release-merge, the build, the wordpress.org deploy, then
release-publish with the zip attached.

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`.
[`templates/workflows/release.yml`](../../../templates/workflows/release.yml) is the complete deploy
workflow, this action, the checkout, the build, then release-publish, ready to copy into a plugin
repository.

## What it does

Expand All @@ -46,10 +55,10 @@ In this order, stopping at the first failure:
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 merge commit on the base branch is the `base-sha` output: what the caller checks out and builds
from, and what release-publish 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.
Expand All @@ -63,7 +72,7 @@ does:
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.
release-publish then skips the release.
- 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.

Expand Down Expand Up @@ -109,8 +118,8 @@ fails the step without the marker; running the step again is enough.
- **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 whose tag exists without a published release.** release-publish 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
Expand Down Expand Up @@ -138,7 +147,7 @@ two different branches (when there is a develop), and the release branch cannot
|---|---|
| `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. |
| `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 and build from; pass it to release-publish as `target`. |
| `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
Expand Down Expand Up @@ -177,8 +186,8 @@ are protected from deletion and non-fast-forward pushes, and `github.token` writ
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.
a personal access token. What the token needs for the tag is in
[release-publish → The token](../release-publish/README.md#the-token).

## How it runs

Expand Down
11 changes: 6 additions & 5 deletions .github/actions/release-merge/action.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# 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`).
# plugin; what happens after the merge (building, wordpress.org, announcements) is the caller's, and
# publishing the GitHub release is the release-publish action'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,
Expand All @@ -23,7 +24,7 @@ inputs:
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.
the run a retry. The release itself is published by release-publish, after this action.
required: true
base-branch:
description: 'The branch the release is merged into: `main`, or `master` where that is the name.'
Expand Down Expand Up @@ -55,8 +56,8 @@ outputs:
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.
retry that found the merge already done. The commit to check out and build from; pass it to
release-publish as `target`.
value: ${{ steps.merge.outputs.base-sha }}
conflict:
description: >-
Expand Down
133 changes: 133 additions & 0 deletions .github/actions/release-publish/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# release-publish

Publishes the GitHub release for a version at a commit: the tag and the release in one step, with
the title, the notes and the assets the caller chooses. It is the second half of a deploy, after
[release-merge](../release-merge/README.md) has merged the release and the caller has built it:

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

- uses: actions/checkout@v6
with:
ref: ${{ steps.merge.outputs.base-sha }}

# Build the plugin here, from the merged commit.

- uses: stellarwp/plugin-toolbox/.github/actions/release-publish@v1
with:
version: ${{ inputs.version }}
target: ${{ steps.merge.outputs.base-sha }}
files: |
build/my-plugin.zip
```

`target` is the commit the release is made at: release-merge's `base-sha` output, as above, which is
also the commit checked out and built between the two actions. The tag is made at that commit,
whatever the base branch points to by the time this runs.
[`templates/workflows/release.yml`](../../../templates/workflows/release.yml) is the complete deploy
workflow around the two actions, ready to copy into a plugin repository.

The action also runs on its own, for a release a person merged by hand: check out the branch,
record its commit, and pass that as `target` (a branch name would be resolved to its tip when the
action runs, after the checkout):

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

- id: head
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"

- uses: stellarwp/plugin-toolbox/.github/actions/release-publish@v1
with:
version: ${{ inputs.version }}
target: ${{ steps.head.outputs.sha }}
```

## What it does

In this order, stopping at the first failure:

1. Checks the inputs: the version (three or four numeric parts), the target, and that every file
in `files` and the `notes-file` exist in the checkout.
2. Reads the release for the version. One that exists is skipped, so a run can be repeated after a
late failure; a tag that exists without a release is refused, since publishing would release
whatever commit the tag points at.
3. Resolves the target to a commit: a branch name becomes its tip.
4. Decides whether the release takes "Latest": it does, unless the repository's current latest
release is a higher version, as when a hotfix goes out on an older line after a newer version
is already released.
5. Without `files`, creates the release at the commit: tag and title, the notes from `notes-file`
or the ones GitHub generates from the pull requests since the previous release.
6. With `files`, creates the release as a **draft**, uploads every file, then publishes it. A draft
makes no tag, so an upload that fails is undone by deleting the draft: nothing is left behind,
and the release is never public without its files. That is also what a repository with
immutable releases needs, since a published release cannot be changed afterwards.

## Inputs

| Input | Required | Default | What it is |
|---|---|---|---|
| `version` | yes | | The version being released: three or four numeric parts. It becomes the tag. |
| `target` | yes | | The commit to publish at: a full commit hash, or a branch name for its tip. |
| `title` | no | the version | The release title, e.g. `Maintenance Release 6.18.3`. |
| `notes-file` | no | | A file in the checkout whose contents are the notes, e.g. this version's changelog entry. Empty: GitHub generates them. |
| `files` | no | | Assets to attach, one path per line, relative to the checkout. |
| `token` | no | `github.token` | The token the release is created with. Needs `contents: write`. See [The token](#the-token). |

## Outputs

| Output | What it is |
|---|---|
| `published` | `true` when the release was created, `skipped` when it already existed. |
| `release-url` | The release page. |
| `sha` | The commit the release was created at; empty when it already existed. |

## Running it again

A release that already exists is skipped and its URL is still reported, so a deploy that failed
after publishing is rerun as it is. An existing release is not changed: a file that was not
attached because the run died between the publish and a later step stays missing, and is added by
hand, or not at all on a repository with immutable releases. With `files`, the window is closed:
the release is only published once every file is on it.

## The token

The job needs `contents: write`. The default `github.token` is enough when the repository's tag
rules allow it to create a tag: a tag ruleset should block deletion and updates, not creation.
GiveWP's rules are the reference shape (branches protected from deletion and non-fast-forward
pushes, no tag rule), and `github.token` publishes there.

One case the default token cannot cover: a release at a commit that adds or changes a file under
`.github/workflows/` relative to the repository's default branch. GitHub requires a token allowed
to modify workflows for that (a GitHub App installation token with the Workflows permission, or a
personal access token with the `workflow` scope) and answers 404 to `github.token`. After
release-merge the commit published is the base branch's own tip, so there is no such difference and
`github.token` publishes; the case is the publish of a branch a person merged by hand, when that
branch changed a workflow.

A release published with the default `github.token` triggers no `release` workflow: GitHub does
not start workflows from events a workflow's own token caused. The run that merged and published
the release is the one that deploys it, so a repository that also has an `on: release` deploy does
not deploy it twice. **A personal access token or a GitHub App token does trigger `on: release`**:
a repository whose tag rules require one of those, because `github.token` cannot be on a ruleset's
bypass list, deploys twice unless the `on: release` workflow is given the same concurrency group
and ignores releases created by the bot, or is retired. Prefer a GitHub App installation token
minted in the run over a personal access token: it is short-lived and scoped to the run.

## How it runs

One [actions/github-script](https://github.com/actions/github-script) step runs
[`release-publish.js`](./release-publish.js). The inputs reach the script through environment
variables, never interpolated into it, so a crafted value is data rather than part of the program.
Everything goes through github-script's Octokit; the checkout is only read, for the notes file and
the assets.

The tests, `node --test` from the repository root, run the script against a recorded client and
real files in a temporary directory.
84 changes: 84 additions & 0 deletions .github/actions/release-publish/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Publishes the GitHub release for a version at a given commit: tag and release in one step, with the
# title, the notes and the assets the caller chooses. The second half of a deploy: release-merge
# merges the release and leaves the commit to publish at in its `base-sha` output, the caller builds
# from the checkout, then this action publishes with the build attached.
#
# The logic lives in release-publish.js, run by actions/github-script, so there is no dependency to
# install and nothing to build. Everything goes through the GitHub API; the checkout is only read,
# for the notes file and the assets.
name: Release publish
description: Publishes the GitHub release for a version at a commit, with a title, notes and assets

inputs:
version:
description: >-
The version being released: three or four numeric parts, such as 4.17.0 or 4.17.0.1. It becomes
the tag. A pre-release such as 4.17.0-beta.1 is refused, and so is a version whose tag already
exists without a published release. A version already published is skipped, so a run can be
repeated after a late failure.
required: true
target:
description: >-
The commit to publish at: a full commit hash, or a branch name for its tip. After release-merge,
pass its `base-sha` output; for a release a person merged by hand, the branch. The tag is made
at this commit, whatever the branch points to by the time this runs.
required: true
title:
description: 'The release title. The version when empty.'
required: false
default: ''
notes-file:
description: >-
A file in the checkout whose contents are the release notes, e.g. the changelog entry of this
version. When empty, GitHub generates the notes from the pull requests since the previous
release.
required: false
default: ''
files:
description: >-
Assets to attach, one path per line, relative to the checkout. With assets the release is created
as a draft, the files are uploaded, and the draft is published last: a draft makes no tag, so an
upload that fails leaves nothing behind. Every path is checked before anything is created.
required: false
default: ''
token:
description: >-
Token the release is created with. It needs `contents: write` on the repository, so the job
needs that permission too. A release published with the default token triggers no `release`
workflow; one published with a personal access token or a GitHub App token does.
required: false
default: ${{ github.token }}

outputs:
published:
description: '`true` when the release was created, `skipped` when it already existed.'
value: ${{ steps.publish.outputs.published }}
release-url:
description: 'The GitHub release page.'
value: ${{ steps.publish.outputs.release-url }}
sha:
description: 'The commit the release was created at; empty when it already existed.'
value: ${{ steps.publish.outputs.sha }}

runs:
using: 'composite'
steps:
- name: Publish the GitHub release
id: publish
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.
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_VERSION: ${{ inputs.version }}
INPUT_TARGET: ${{ inputs.target }}
INPUT_TITLE: ${{ inputs.title }}
INPUT_NOTES_FILE: ${{ inputs.notes-file }}
INPUT_FILES: ${{ inputs.files }}
with:
github-token: ${{ inputs.token }}
script: |
const { runPublish } = require(`${process.env.ACTION_PATH}/release-publish.js`)
await runPublish({ github, core, env: process.env })
Loading
Loading