diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md
index ac7532b..7d50cde 100644
--- a/.github/actions/release-merge/README.md
+++ b/.github/actions/release-merge/README.md
@@ -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
@@ -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.
@@ -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.
@@ -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
@@ -150,7 +159,7 @@ this; without `head`, the tip at that moment is what merges.
|---|---|
| `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
@@ -189,8 +198,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
diff --git a/.github/actions/release-merge/action.yml b/.github/actions/release-merge/action.yml
index c947758..0bbbfbf 100644
--- a/.github/actions/release-merge/action.yml
+++ b/.github/actions/release-merge/action.yml
@@ -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,
@@ -30,7 +31,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.'
@@ -62,8 +63,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: >-
diff --git a/.github/actions/release-publish/README.md b/.github/actions/release-publish/README.md
new file mode 100644
index 0000000..2d45c6e
--- /dev/null
+++ b/.github/actions/release-publish/README.md
@@ -0,0 +1,162 @@
+# 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 }}
+```
+
+Give that workflow the `concurrency` group the template has, so one publish of a version runs at a
+time: two at once race for the tag, and the action refuses the second (see
+[Tags it refuses](#tags-it-refuses)).
+
+## 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. The previous
+ release is chosen here, not left to GitHub: the highest published version below this one, so a
+ hotfix on an older line (`2.1.38.3` after `2.1.46` is out) gets the changes since `2.1.38.2`,
+ not since `2.1.46`. A first release, with nothing below it, leaves the start to GitHub.
+6. With `files`, creates the release as a **draft** named `
[release-publish draft]`,
+ uploads every file, then publishes it under `title`. A draft makes no tag, so an upload that
+ fails, or a publish the repository refuses (a tag ruleset that blocks the tag), 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. When the draft cannot be deleted, the
+ error says so and where to delete it by hand.
+
+## 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: release-merge's `base-sha`, or the commit of the checkout the assets were built from. A branch name is resolved to its tip when the action runs. |
+| `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. |
+
+## Tags it refuses
+
+A tag named after the version that exists before the action runs, without a published release, is
+refused: publishing would release whatever commit the tag points at. GitHub ignores the commit
+asked for when the tag already exists, so a tag created by someone else between that check and the
+publish would do the same. The action reads the tag again where it matters:
+
+- With `files`, before the draft is published. A tag that appeared while the files were uploading
+ makes the action delete the draft and fail, naming the tag's commit; nothing is published.
+- Without `files`, right after the release is created. A tag that points at another commit makes
+ the action fail, naming the commit the release is at and its URL: the release is public by then,
+ so the failure says what to delete rather than undoing it.
+
+## 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.
+
+A draft the action created and left behind, by a run cancelled while it was uploading or whose
+draft could not be deleted, is deleted by the next run of the same version before it publishes; a
+draft is not found by its tag, so it would otherwise stay there next to the new one. The action
+knows its own drafts by the ` [release-publish draft]` it appends to their name until they are
+published; a draft a person made by hand for the same version, to write the notes in the GitHub UI,
+has no mark and is left alone. When that delete fails, the run warns and goes on.
+
+## 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.
diff --git a/.github/actions/release-publish/action.yml b/.github/actions/release-publish/action.yml
new file mode 100644
index 0000000..57980fb
--- /dev/null
+++ b/.github/actions/release-publish/action.yml
@@ -0,0 +1,86 @@
+# 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, which is resolved to its tip
+ when the action runs. After release-merge, pass its `base-sha` output. For a release a person
+ merged by hand, pass the commit of the checkout the assets were built from, not the branch: a
+ push after the checkout would be tagged with assets built from another commit. The README
+ shows how.
+ 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 })
diff --git a/.github/actions/release-publish/release-publish.js b/.github/actions/release-publish/release-publish.js
new file mode 100644
index 0000000..44f763d
--- /dev/null
+++ b/.github/actions/release-publish/release-publish.js
@@ -0,0 +1,502 @@
+'use strict'
+
+/**
+ * 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.
+ *
+ * It is the second half of a deploy, after release-merge has merged the release and the caller has
+ * built it: `target` is the commit release-merge left on the base branch (`base-sha`), or the tip of
+ * a branch a person merged by hand. The release is created at that commit, not at whatever a branch
+ * points to when this runs.
+ *
+ * With assets, the release is created as a draft, the assets are uploaded, and the draft is
+ * published last: a draft makes no tag, so an upload that fails leaves nothing behind once the draft
+ * is deleted, 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.
+ *
+ * Running it again after a late failure is safe: a release that already exists is skipped.
+ */
+
+const fs = require('node:fs')
+const path = require('node:path')
+
+const { STELLARWP_VERSION_PATTERN, compareVersions, validateVersion: validateReleaseVersion } = require('../../lib/versions.js')
+const { readRepository, isNotFound, readTag, tagExists } = require('../../lib/github.js')
+
+/** Closes every error message: nothing of this action's was done. */
+const NOTHING = 'Nothing was published.'
+
+/**
+ * What the action appends to the name of a draft it creates, and takes off when it publishes. A
+ * rerun deletes only drafts that carry it: a draft a person made by hand for the same version, to
+ * write the notes in the GitHub UI, has the same tag name and is left alone.
+ */
+const DRAFT_MARK = ' [release-publish draft]'
+
+/**
+ * Reads the version to release: the shared rule, with this action's closing line.
+ *
+ * @param {string} version The `version` input.
+ *
+ * @throws {Error} When it is empty or not three or four numeric parts.
+ *
+ * @returns {string} The version, without surrounding whitespace.
+ */
+function validateVersion(version) {
+ return validateReleaseVersion(version, NOTHING)
+}
+
+/**
+ * Reads the `target` input: the commit the release is made at, as a full hash or a branch name.
+ *
+ * @param {string} target The input value.
+ *
+ * @throws {Error} When it is empty or a full ref.
+ *
+ * @returns {string} The target, without surrounding whitespace.
+ */
+function readTarget(target) {
+ const trimmed = String(target ?? '').trim()
+
+ if (trimmed === '') {
+ throw new Error(
+ "No target was given: pass the commit to publish at, release-merge's base-sha output or a branch " +
+ `name. ${NOTHING}`
+ )
+ }
+
+ if (trimmed.startsWith('refs/')) {
+ throw new Error(`Refusing target '${trimmed}': pass a commit hash or a branch name, not a ref. ${NOTHING}`)
+ }
+
+ return trimmed
+}
+
+/**
+ * Whether a target is a full commit hash, which needs no lookup.
+ *
+ * @param {string} target The target.
+ *
+ * @returns {boolean} True for 40 hex digits.
+ */
+function isSha(target) {
+ return /^[0-9a-f]{40}$/.test(target)
+}
+
+/**
+ * The commit a target names: the hash itself, or the tip of the branch.
+ *
+ * @param {object} options
+ * @param {object} options.github The Octokit github-script supplies.
+ * @param {string} options.owner The repository owner.
+ * @param {string} options.repo The repository name.
+ * @param {string} options.target The target, from readTarget.
+ *
+ * @throws {Error} When it is neither a hash nor a branch of the repository.
+ *
+ * @returns {Promise} The commit hash.
+ */
+async function resolveTarget({ github, owner, repo, target }) {
+ if (isSha(target)) {
+ return target
+ }
+
+ try {
+ const { data } = await github.rest.repos.getBranch({ owner, repo, branch: target })
+
+ return data.commit.sha
+ } catch (error) {
+ if (isNotFound(error)) {
+ throw new Error(`'${target}' is not a commit hash, and the repository has no branch by that name. ${NOTHING}`)
+ }
+
+ throw new Error(`Reading branch ${target} failed (HTTP ${error.status}): ${error.message}. ${NOTHING}`)
+ }
+}
+
+/**
+ * Reads the `title` input, the version when it is empty.
+ *
+ * @param {string} title The input value.
+ * @param {string} version The version, validated.
+ *
+ * @returns {string} The release title.
+ */
+function readTitle(title, version) {
+ const trimmed = String(title ?? '').trim()
+
+ return trimmed === '' ? version : trimmed
+}
+
+/**
+ * Reads the notes file, when one was given.
+ *
+ * @param {string} input The `notes-file` input, a path relative to the checkout.
+ * @param {string} cwd The checkout.
+ *
+ * @throws {Error} When the file is not there.
+ *
+ * @returns {string|null} The notes, or null to let GitHub generate them.
+ */
+function readNotesFile(input, cwd) {
+ const file = String(input ?? '').trim()
+
+ if (file === '') {
+ return null
+ }
+
+ try {
+ return fs.readFileSync(path.resolve(cwd, file), 'utf8')
+ } catch (error) {
+ throw new Error(`The notes file ${file} could not be read: ${error.message}. ${NOTHING}`)
+ }
+}
+
+/**
+ * Reads the `files` input: the assets to attach, one path per line, relative to the checkout. Every
+ * one is checked before anything is created, so a missing file fails the run with nothing to undo.
+ *
+ * @param {string} input The input value.
+ * @param {string} cwd The checkout.
+ *
+ * @throws {Error} When a path is not a file.
+ *
+ * @returns {{name: string, file: string, size: number}[]} The assets, in the order given.
+ */
+function readFiles(input, cwd) {
+ const lines = String(input ?? '')
+ .split('\n')
+ .map((line) => line.trim())
+ .filter(Boolean)
+
+ return lines.map((line) => {
+ const file = path.resolve(cwd, line)
+ let stat
+ try {
+ stat = fs.statSync(file)
+ } catch {
+ stat = null
+ }
+ if (!stat?.isFile()) {
+ throw new Error(`${line}, listed in files, is not a file in the checkout. ${NOTHING}`)
+ }
+
+ return { name: path.basename(file), file, size: stat.size }
+ })
+}
+
+/**
+ * The tag of the repository's current latest release, or null when it has none.
+ *
+ * @param {object} options
+ * @param {object} options.github The Octokit github-script supplies.
+ * @param {string} options.owner The repository owner.
+ * @param {string} options.repo The repository name.
+ *
+ * @throws {Error} When the read fails for any reason but 404.
+ *
+ * @returns {Promise} The latest release's tag.
+ */
+async function latestReleaseTag({ github, owner, repo }) {
+ try {
+ const { data } = await github.rest.repos.getLatestRelease({ owner, repo })
+
+ return data.tag_name
+ } catch (error) {
+ if (isNotFound(error)) {
+ return null
+ }
+
+ throw new Error(`Reading the latest release failed (HTTP ${error.status}): ${error.message}. ${NOTHING}`)
+ }
+}
+
+/**
+ * The repository's releases, every page.
+ *
+ * @param {object} options
+ * @param {object} options.github The Octokit github-script supplies.
+ * @param {string} options.owner The repository owner.
+ * @param {string} options.repo The repository name.
+ *
+ * @throws {Error} When the read fails.
+ *
+ * @returns {Promise