From ef9bebde65c5d8f2538488b0d95e3e167911091f Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 16:33:37 -0300 Subject: [PATCH 01/14] Add release-publish: the GitHub release as its own action, with a title, notes and assets release-merge published the release as its second step, right after the merges and before the caller built anything, so a release went public without its build and a failed build left a published release behind; with immutable releases that could not be corrected. Publishing is now the release-publish action, which the caller runs after its build at the commit release-merge leaves on the base (its base-sha output). release-publish takes a title (the version by default), a notes file (GitHub's generated notes by default) and assets, one path per line. 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 is undone by deleting it. A hotfix on an older line no longer takes Latest from a newer release (make_latest by version order). A release that already exists is skipped, as before. validateVersion moved to the shared lib for both actions. release-merge loses the publish input and the published / release-url outputs; the template runs the two actions with the build between them. SOFT-5188 --- .github/actions/release-merge/README.md | 32 +- .github/actions/release-merge/action.yml | 11 +- .github/actions/release-publish/README.md | 101 +++++ .github/actions/release-publish/action.yml | 84 ++++ .../release-publish/release-publish.js | 353 +++++++++++++++++ README.md | 3 +- .../release-publish/release-publish.test.js | 362 ++++++++++++++++++ tests/support/actions.js | 24 +- 8 files changed, 949 insertions(+), 21 deletions(-) create mode 100644 .github/actions/release-publish/README.md create mode 100644 .github/actions/release-publish/action.yml create mode 100644 .github/actions/release-publish/release-publish.js create mode 100644 tests/actions/release-publish/release-publish.test.js diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md index ac7532b..647c688 100644 --- a/.github/actions/release-merge/README.md +++ b/.github/actions/release-merge/README.md @@ -16,12 +16,16 @@ 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. 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 @@ -46,10 +50,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 +67,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 +113,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 +154,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 +193,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..18244f0 --- /dev/null +++ b/.github/actions/release-publish/README.md @@ -0,0 +1,101 @@ +# 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 }} + +# Build the plugin here, from the checkout the merge left on the base branch. + +- 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, or a branch name +for a release a person merged by hand. The tag is made at that commit, whatever the branch points +to by the time this runs. [`templates/workflows/release-merge.yml`](../../../templates/workflows/release-merge.yml) +is a complete workflow around the two actions, ready to copy into a plugin repository. + +## 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. + +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..2a064d0 --- /dev/null +++ b/.github/actions/release-publish/action.yml @@ -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 }) diff --git a/.github/actions/release-publish/release-publish.js b/.github/actions/release-publish/release-publish.js new file mode 100644 index 0000000..a02f589 --- /dev/null +++ b/.github/actions/release-publish/release-publish.js @@ -0,0 +1,353 @@ +'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, tagExists } = require('../../lib/github.js') + +/** Closes every error message: nothing of this action's was done. */ +const NOTHING = 'Nothing was published.' + +/** + * 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}`) + } +} + +/** + * Whether the new release takes "Latest": yes, unless the current latest is a higher version, as + * when a hotfix goes out on an older line after a newer version is released. A latest whose tag is + * not a plain version (a `v` prefix, a name) is not compared, and the new release takes it. + * + * @param {string} version The version being released. + * @param {string|null} latest The current latest release's tag. + * + * @returns {'true'|'false'} The `make_latest` value the API takes. + */ +function makeLatest(version, latest) { + if (latest && STELLARWP_VERSION_PATTERN.test(latest) && compareVersions(version, latest) < 0) { + return 'false' + } + + return 'true' +} + +/** + * Publishes the release, unless it already exists. + * + * @param {object} options + * @param {object} options.github The Octokit github-script supplies, authenticated with the token. + * @param {object} options.core The @actions/core toolkit, for the log, the outputs and the summary. + * @param {object} options.env The environment the action manifest put its inputs in. + * @param {string} options.cwd The checkout, the working directory by default. + * + * @throws {Error} When an input is wrong, a file is missing, the tag exists without a release, or + * an API call fails. + * + * @returns {Promise} Resolves once the release exists, or was found to exist already. + */ +async function runPublish({ github, core, env, cwd = process.cwd() }) { + const version = validateVersion(env.INPUT_VERSION) + const target = readTarget(env.INPUT_TARGET) + const title = readTitle(env.INPUT_TITLE, version) + const notes = readNotesFile(env.INPUT_NOTES_FILE, cwd) + const files = readFiles(env.INPUT_FILES, cwd) + const { owner, repo } = readRepository(env, NOTHING) + + let existing = null + try { + existing = (await github.rest.repos.getReleaseByTag({ owner, repo, tag: version })).data + } catch (error) { + if (!isNotFound(error)) { + throw new Error(`Reading the release for ${version} failed (HTTP ${error.status}): ${error.message}. ${NOTHING}`) + } + } + + if (existing) { + core.info(`${version} is already published (${existing.html_url}): nothing to publish.`) + core.setOutput('published', 'skipped') + core.setOutput('release-url', existing.html_url) + core.setOutput('sha', '') + await core.summary.addRaw(`Release ${version} was already published: ${existing.html_url}`, true).write() + + return + } + + if (await tagExists({ github, owner, repo, version, what: NOTHING })) { + throw new Error( + `The tag ${version} already exists, but there is no published release for it, so publishing ` + + `would release whatever commit the tag points at. Delete the tag or release another version. ${NOTHING}` + ) + } + + const sha = await resolveTarget({ github, owner, repo, target }) + const latest = makeLatest(version, await latestReleaseTag({ github, owner, repo })) + if (latest === 'false') { + core.info(`${version} is below the current latest release, so it is published without taking "Latest".`) + } + + const body = + notes ?? + (await github.rest.repos.generateReleaseNotes({ owner, repo, tag_name: version, target_commitish: sha })).data.body + + let release + if (files.length === 0) { + release = ( + await github.rest.repos.createRelease({ + owner, + repo, + tag_name: version, + target_commitish: sha, + name: title, + body, + make_latest: latest, + }) + ).data + } else { + // A draft makes no tag, so a failed upload can be undone by deleting it, and the release is + // never public without its files. + const draft = ( + await github.rest.repos.createRelease({ + owner, + repo, + tag_name: version, + target_commitish: sha, + name: title, + body, + draft: true, + }) + ).data + + for (const asset of files) { + try { + await github.rest.repos.uploadReleaseAsset({ + owner, + repo, + release_id: draft.id, + name: asset.name, + data: fs.readFileSync(asset.file), + headers: { 'content-type': 'application/octet-stream', 'content-length': asset.size }, + }) + } catch (error) { + await github.rest.repos.deleteRelease({ owner, repo, release_id: draft.id }).catch(() => {}) + throw new Error( + `Uploading ${asset.name} to the release failed (HTTP ${error.status}): ${error.message}. ` + + `The draft was deleted and no tag was made. ${NOTHING}` + ) + } + core.info(`Attached ${asset.name}.`) + } + + release = ( + await github.rest.repos.updateRelease({ owner, repo, release_id: draft.id, draft: false, make_latest: latest }) + ).data + } + + core.info(`Published ${version} at ${sha.slice(0, 7)}: ${release.html_url}`) + core.setOutput('published', 'true') + core.setOutput('release-url', release.html_url) + core.setOutput('sha', sha) + const attached = files.length === 0 ? '' : ` with ${files.map((asset) => `\`${asset.name}\``).join(', ')}` + await core.summary.addRaw(`Published release ${version} at \`${sha.slice(0, 7)}\`${attached}: ${release.html_url}`, true).write() +} + +module.exports = { + runPublish, + validateVersion, + readTarget, + resolveTarget, + readTitle, + readNotesFile, + readFiles, + makeLatest, +} diff --git a/README.md b/README.md index 964c710..f3fe5bb 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,8 @@ Each phase ends with something people can actually use. - [ ] Port `check-php-changes`, `smart-checkout`, `process-changelog`, `add-changelog`, `generate-pot` from TEC. - [ ] Add `setup-node` for the npm crowd. - [x] Add `release-prep`: version bump, TBD replacement and changelog through pup and the changelogger action, committed to the release branch through the GitHub API. A caller to copy is in `templates/workflows/release-prep.yml`. -- [x] Add `release-merge`: the release branch into the base branch, the base back into develop, each step safe to rerun and a conflict reported with a `MERGE_CONFLICT` line. +- [x] Add `release-merge`: the release branch into the base branch, the base back into develop, each step safe to rerun and a conflict reported with a `MERGE_CONFLICT` line. A caller to copy is in `templates/workflows/release-merge.yml`. +- [x] Add `release-publish`: the GitHub release for a version at a commit, with a title, notes from a file and assets uploaded before the release is public, skipped when it already exists. The template above runs it after the build. - [ ] Turn every hard-coded repo name, slug, or branch pattern into an input. - [ ] Self-checks: actionlint everything, unit test anything with real logic. - [ ] Tag `v1.0.0`. diff --git a/tests/actions/release-publish/release-publish.test.js b/tests/actions/release-publish/release-publish.test.js new file mode 100644 index 0000000..4f2a657 --- /dev/null +++ b/tests/actions/release-publish/release-publish.test.js @@ -0,0 +1,362 @@ +'use strict' + +const { describe, it } = require('node:test') +const assert = require('node:assert') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +const { + runPublish, + validateVersion, + readTarget, + resolveTarget, + readTitle, + readNotesFile, + readFiles, + makeLatest, +} = require('../../../.github/actions/release-publish/release-publish.js') +const { coreWithOutputs, octokitFor } = require('../../support/actions.js') + +/** The commit release-merge hands over as `base-sha`. */ +const SHA = '0123456789abcdef0123456789abcdef01234567' + +/** The repository paths the action reads and writes, as a real Octokit spells them. */ +const API = { + tag: '/repos/stellarwp/plugin/git/ref/tags%2F4.17.0', + releaseByTag: '/repos/stellarwp/plugin/releases/tags/4.17.0', + latest: '/repos/stellarwp/plugin/releases/latest', + branch: '/repos/stellarwp/plugin/branches/main', + notes: '/repos/stellarwp/plugin/releases/generate-notes', + releases: '/repos/stellarwp/plugin/releases', + release: '/repos/stellarwp/plugin/releases/77', + assets: /^https:\/\/uploads\.github\.com\/repos\/stellarwp\/plugin\/releases\/77\/assets\?name=/, +} + +const RELEASE_URL = 'https://github.com/stellarwp/plugin/releases/tag/4.17.0' + +/** The rules of the common case: no tag, no release yet, a lower latest, notes generated. */ +const FRESH = [ + { method: 'POST', path: API.notes, body: { name: '4.17.0', body: '## What changed\n* Fix (#12)' } }, + { method: 'GET', path: API.latest, body: { tag_name: '4.16.0' } }, +] + +/** + * The environment the manifest hands the script. + * + * @param {object} overrides Inputs to change. + * + * @returns {object} The environment. + */ +function publishEnv(overrides = {}) { + return { + INPUT_VERSION: '4.17.0', + INPUT_TARGET: SHA, + INPUT_TITLE: '', + INPUT_NOTES_FILE: '', + INPUT_FILES: '', + GITHUB_REPOSITORY: 'stellarwp/plugin', + ...overrides, + } +} + +/** + * A checkout holding the files a test names. + * + * @param {object} files Name to contents. + * + * @returns {string} The directory. + */ +function checkoutWith(files = {}) { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'release-publish-')) + for (const [name, contents] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(cwd, name)), { recursive: true }) + fs.writeFileSync(path.join(cwd, name), contents) + } + + return cwd +} + +/** + * Each request as `METHOD path`, for asserting what the action asked the API. + * + * @param {object[]} requests The requests recordingFetch recorded. + * + * @returns {string[]} One line per request. + */ +function requested(requests) { + // The upload URL comes from the `{?name,label}` template, which leaves a trailing `&` for the + // empty label. + return requests.map((request) => `${request.method} ${request.path.replace(/^https:\/\/uploads\.github\.com/, 'uploads:').replace(/&$/, '')}`) +} + +describe('release-publish', () => { + describe('validateVersion', () => { + it('closes its message with what was not done', () => { + assert.strictEqual(validateVersion(' 4.17.0 '), '4.17.0') + assert.throws(() => validateVersion('4.17.0-beta.1'), /not a version this action can release.*Nothing was published/s) + assert.throws(() => validateVersion(''), /No version was given.*Nothing was published/s) + }) + }) + + describe('readTarget', () => { + it('takes a hash or a branch name, never a ref or nothing', () => { + assert.strictEqual(readTarget(` ${SHA} `), SHA) + assert.strictEqual(readTarget('main'), 'main') + assert.throws(() => readTarget(''), /No target was given.*base-sha/s) + assert.throws(() => readTarget('refs/heads/main'), /not a ref/) + }) + }) + + describe('resolveTarget', () => { + it('returns a hash as it is, without asking the API', async () => { + const { github, requests } = await octokitFor([]) + + assert.strictEqual(await resolveTarget({ github, owner: 'stellarwp', repo: 'plugin', target: SHA }), SHA) + assert.deepStrictEqual(requests, []) + }) + + it('resolves a branch name to its tip', async () => { + const { github } = await octokitFor([{ method: 'GET', path: API.branch, body: { commit: { sha: SHA } } }]) + + assert.strictEqual(await resolveTarget({ github, owner: 'stellarwp', repo: 'plugin', target: 'main' }), SHA) + }) + + it('refuses a name that is neither a hash nor a branch', async () => { + const { github } = await octokitFor([]) + + await assert.rejects( + resolveTarget({ github, owner: 'stellarwp', repo: 'plugin', target: 'nope' }), + /'nope' is not a commit hash, and the repository has no branch by that name/ + ) + }) + }) + + describe('readTitle', () => { + it('is the version unless a title was given', () => { + assert.strictEqual(readTitle('', '4.17.0'), '4.17.0') + assert.strictEqual(readTitle(' Maintenance Release 4.17.0 ', '4.17.0'), 'Maintenance Release 4.17.0') + }) + }) + + describe('readNotesFile', () => { + it('reads the file from the checkout, and names it when it is missing', () => { + const cwd = checkoutWith({ 'notes.md': '* Fixed a thing\n' }) + + assert.strictEqual(readNotesFile('', cwd), null) + assert.strictEqual(readNotesFile('notes.md', cwd), '* Fixed a thing\n') + assert.throws(() => readNotesFile('missing.md', cwd), /notes file missing\.md could not be read.*Nothing was published/s) + }) + }) + + describe('readFiles', () => { + it('lists one asset per line, each checked to be a file in the checkout', () => { + const cwd = checkoutWith({ 'build/plugin.zip': 'zip', 'readme.txt': 'readme' }) + + assert.deepStrictEqual(readFiles('', cwd), []) + assert.deepStrictEqual( + readFiles('\nbuild/plugin.zip\n readme.txt \n', cwd).map(({ name, size }) => ({ name, size })), + [ + { name: 'plugin.zip', size: 3 }, + { name: 'readme.txt', size: 6 }, + ] + ) + assert.throws(() => readFiles('build/plugin.zip\nbuild', cwd), /^Error: build, listed in files, is not a file/) + assert.throws(() => readFiles('missing.zip', cwd), /missing\.zip, listed in files, is not a file.*Nothing was published/s) + }) + }) + + describe('makeLatest', () => { + it('keeps "Latest" on a newer release when an older line gets a hotfix', () => { + assert.strictEqual(makeLatest('4.17.0', null), 'true') + assert.strictEqual(makeLatest('4.17.0', '4.16.0'), 'true') + assert.strictEqual(makeLatest('4.17.0', '4.17.0.1'), 'false') + assert.strictEqual(makeLatest('4.16.1', '4.17.0'), 'false') + // A latest whose tag is not a plain version is not compared. + assert.strictEqual(makeLatest('4.16.1', 'v4.17.0'), 'true') + }) + }) + + describe('runPublish', () => { + it('publishes the release at the commit, titled with the version, with generated notes', async (t) => { + const { github, requests } = await octokitFor([...FRESH, { method: 'POST', path: API.releases, status: 201, body: { id: 77, html_url: RELEASE_URL } }]) + const { core, outputs, summary } = await coreWithOutputs(t) + + await runPublish({ github, core, env: publishEnv(), cwd: checkoutWith() }) + + assert.deepStrictEqual(requested(requests), [ + `GET ${API.releaseByTag}`, + `GET ${API.tag}`, + `GET ${API.latest}`, + `POST ${API.notes}`, + `POST ${API.releases}`, + ]) + assert.deepStrictEqual(requests[3].body, { tag_name: '4.17.0', target_commitish: SHA }) + assert.deepStrictEqual(requests[4].body, { + tag_name: '4.17.0', + target_commitish: SHA, + name: '4.17.0', + body: '## What changed\n* Fix (#12)', + make_latest: 'true', + }) + assert.deepStrictEqual(outputs(), { published: 'true', 'release-url': RELEASE_URL, sha: SHA }) + assert.match(summary(), /Published release 4\.17\.0 at `0123456`/) + }) + + it('publishes at the tip of a branch given as target, with the title and the notes file', async (t) => { + const cwd = checkoutWith({ 'notes.md': '= 4.17.0 =\n* Fixed a thing\n' }) + const { github, requests } = await octokitFor([ + { method: 'GET', path: API.branch, body: { commit: { sha: SHA } } }, + { method: 'GET', path: API.latest, body: { tag_name: '4.16.0' } }, + { method: 'POST', path: API.releases, status: 201, body: { id: 77, html_url: RELEASE_URL } }, + ]) + const { core } = await coreWithOutputs(t) + + await runPublish({ + github, + core, + env: publishEnv({ INPUT_TARGET: 'main', INPUT_TITLE: 'Maintenance Release 4.17.0', INPUT_NOTES_FILE: 'notes.md' }), + cwd, + }) + + // No generated notes were asked for: the file is the body. + assert.ok(!requested(requests).includes(`POST ${API.notes}`)) + assert.deepStrictEqual(requests.at(-1).body, { + tag_name: '4.17.0', + target_commitish: SHA, + name: 'Maintenance Release 4.17.0', + body: '= 4.17.0 =\n* Fixed a thing\n', + make_latest: 'true', + }) + }) + + it('does not take "Latest" from a newer release when it is a hotfix on an older line', async (t) => { + const { github, requests } = await octokitFor([ + { method: 'POST', path: API.notes, body: { body: 'notes' } }, + { method: 'GET', path: API.latest, body: { tag_name: '4.18.0' } }, + { method: 'POST', path: API.releases, status: 201, body: { id: 77, html_url: RELEASE_URL } }, + ]) + const { core, logged } = await coreWithOutputs(t) + + await runPublish({ github, core, env: publishEnv(), cwd: checkoutWith() }) + + assert.strictEqual(requests.at(-1).body.make_latest, 'false') + assert.match(logged(), /below the current latest release/) + }) + + it('takes "Latest" when the repository has no release yet', async (t) => { + const { github, requests } = await octokitFor([ + { method: 'POST', path: API.notes, body: { body: 'notes' } }, + { method: 'POST', path: API.releases, status: 201, body: { id: 77, html_url: RELEASE_URL } }, + ]) + const { core } = await coreWithOutputs(t) + + await runPublish({ github, core, env: publishEnv(), cwd: checkoutWith() }) + + assert.strictEqual(requests.at(-1).body.make_latest, 'true') + }) + + it('uploads the files to a draft and publishes it last, so the release is never public without them', async (t) => { + const cwd = checkoutWith({ 'build/plugin.zip': 'PK zip bytes', 'plugin.pot': 'msgid' }) + const { github, requests } = await octokitFor([ + ...FRESH, + { method: 'POST', path: API.releases, status: 201, body: { id: 77, html_url: RELEASE_URL, upload_url: 'https://uploads.github.com/repos/stellarwp/plugin/releases/77/assets{?name,label}' } }, + { method: 'POST', path: API.assets, status: 201, body: { id: 1 } }, + { method: 'PATCH', path: API.release, body: { id: 77, html_url: RELEASE_URL } }, + ]) + const { core, outputs, summary, logged } = await coreWithOutputs(t) + + await runPublish({ github, core, env: publishEnv({ INPUT_FILES: 'build/plugin.zip\nplugin.pot\n' }), cwd }) + + assert.deepStrictEqual(requested(requests), [ + `GET ${API.releaseByTag}`, + `GET ${API.tag}`, + `GET ${API.latest}`, + `POST ${API.notes}`, + `POST ${API.releases}`, + 'POST uploads:/repos/stellarwp/plugin/releases/77/assets?name=plugin.zip', + 'POST uploads:/repos/stellarwp/plugin/releases/77/assets?name=plugin.pot', + `PATCH ${API.release}`, + ]) + // Created as a draft, with no make_latest yet: that is decided when it is published. + assert.deepStrictEqual(requests[4].body, { + tag_name: '4.17.0', + target_commitish: SHA, + name: '4.17.0', + body: '## What changed\n* Fix (#12)', + draft: true, + }) + assert.strictEqual(String(requests[5].body), 'PK zip bytes') + assert.deepStrictEqual(requests[7].body, { draft: false, make_latest: 'true' }) + assert.deepStrictEqual(outputs(), { published: 'true', 'release-url': RELEASE_URL, sha: SHA }) + assert.match(logged(), /Attached plugin\.zip.*Attached plugin\.pot/s) + assert.match(summary(), /with `plugin\.zip`, `plugin\.pot`/) + }) + + it('deletes the draft when an upload fails, leaving no release and no tag', async (t) => { + const cwd = checkoutWith({ 'build/plugin.zip': 'zip' }) + const { github, requests } = await octokitFor([ + ...FRESH, + { method: 'POST', path: API.releases, status: 201, body: { id: 77, html_url: RELEASE_URL } }, + { method: 'POST', path: API.assets, status: 502, body: { message: 'bad gateway' } }, + { method: 'DELETE', path: API.release, status: 204, body: {} }, + ]) + const { core, outputs } = await coreWithOutputs(t) + + await assert.rejects( + runPublish({ github, core, env: publishEnv({ INPUT_FILES: 'build/plugin.zip' }), cwd }), + /Uploading plugin\.zip to the release failed \(HTTP 502\).*draft was deleted and no tag was made.*Nothing was published/s + ) + + assert.deepStrictEqual(requested(requests).slice(-2), ['POST uploads:/repos/stellarwp/plugin/releases/77/assets?name=plugin.zip', `DELETE ${API.release}`]) + assert.deepStrictEqual(outputs(), {}) + }) + + it('refuses a missing file before creating anything', async (t) => { + const { github, requests } = await octokitFor(FRESH) + const { core } = await coreWithOutputs(t) + + await assert.rejects( + runPublish({ github, core, env: publishEnv({ INPUT_FILES: 'build/plugin.zip' }), cwd: checkoutWith() }), + /build\/plugin\.zip, listed in files, is not a file/ + ) + assert.deepStrictEqual(requests, []) + }) + + it('skips a release that already exists, so a retry succeeds', async (t) => { + const { github, requests } = await octokitFor([{ method: 'GET', path: API.releaseByTag, body: { html_url: RELEASE_URL } }]) + const { core, outputs, logged } = await coreWithOutputs(t) + + await runPublish({ github, core, env: publishEnv(), cwd: checkoutWith() }) + + assert.deepStrictEqual(requested(requests), [`GET ${API.releaseByTag}`]) + assert.deepStrictEqual(outputs(), { published: 'skipped', 'release-url': RELEASE_URL, sha: '' }) + assert.match(logged(), /4\.17\.0 is already published/) + }) + + it('refuses a tag that exists without a release, rather than releasing whatever it points at', async (t) => { + const { github, requests } = await octokitFor([{ method: 'GET', path: API.tag, body: { ref: 'refs/tags/4.17.0', object: { sha: 'abc' } } }]) + const { core } = await coreWithOutputs(t) + + await assert.rejects(runPublish({ github, core, env: publishEnv(), cwd: checkoutWith() }), /tag 4\.17\.0 already exists, but there is no published release/) + assert.deepStrictEqual(requested(requests), [`GET ${API.releaseByTag}`, `GET ${API.tag}`]) + }) + + it('fails on an API error reading the release rather than publishing twice', async (t) => { + const { github } = await octokitFor([{ method: 'GET', path: API.releaseByTag, status: 500, body: { message: 'boom' } }]) + const { core } = await coreWithOutputs(t) + + await assert.rejects(runPublish({ github, core, env: publishEnv(), cwd: checkoutWith() }), /Reading the release for 4\.17\.0 failed.*500.*Nothing was published/s) + }) + + it('refuses a wrong version or an empty target before asking the API anything', async (t) => { + const { github, requests } = await octokitFor([]) + const { core } = await coreWithOutputs(t) + + await assert.rejects(runPublish({ github, core, env: publishEnv({ INPUT_VERSION: 'v4.17.0' }), cwd: checkoutWith() }), /not a version this action can release/) + await assert.rejects(runPublish({ github, core, env: publishEnv({ INPUT_TARGET: '' }), cwd: checkoutWith() }), /No target was given/) + await assert.rejects(runPublish({ github, core, env: publishEnv({ GITHUB_REPOSITORY: '' }), cwd: checkoutWith() }), /GITHUB_REPOSITORY is not set/) + assert.deepStrictEqual(requests, []) + }) + }) +}) diff --git a/tests/support/actions.js b/tests/support/actions.js index fd0fb50..caab724 100644 --- a/tests/support/actions.js +++ b/tests/support/actions.js @@ -44,6 +44,28 @@ function jsonResponse({ status = 200, body = {}, headers = {} }) { }) } +/** + * The body of a recorded request: JSON parsed, anything else (an asset upload) as it was sent. + * + * @param {*} body What the request carried. + * + * @returns {*} The parsed JSON, the raw body, or undefined for none. + */ +function parseBody(body) { + if (body === undefined || body === null) { + return undefined + } + if (typeof body === 'string') { + try { + return JSON.parse(body) + } catch { + return body + } + } + + return body +} + /** * A `fetch` that answers from a list of rules and records every request it was given. * @@ -75,7 +97,7 @@ function recordingFetch(rules = []) { method, url: href, path: requestPath, - body: options.body ? JSON.parse(options.body) : undefined, + body: parseBody(options.body), }) for (const [index, rule] of rules.entries()) { From 80660239f2470ec1bfc28f7c86318faf5cc10dcb Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 17:25:12 -0300 Subject: [PATCH 02/14] One template per action: release-merge.yml merges, release-publish.yml publishes The merge template had grown a publish step. Each template now shows one action; a repository that merges with release-merge adds release-publish to that workflow after its build, as GiveWP's deploy workflow does, and the READMEs say so. SOFT-5188 --- .github/actions/release-merge/README.md | 4 +- .github/actions/release-publish/README.md | 6 ++- README.md | 2 +- templates/workflows/release-publish.yml | 63 +++++++++++++++++++++++ 4 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 templates/workflows/release-publish.yml diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md index 647c688..7d69b63 100644 --- a/.github/actions/release-merge/README.md +++ b/.github/actions/release-merge/README.md @@ -25,7 +25,9 @@ for it**, set with `base-branch`: 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. +(`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 diff --git a/.github/actions/release-publish/README.md b/.github/actions/release-publish/README.md index 18244f0..35bf4e1 100644 --- a/.github/actions/release-publish/README.md +++ b/.github/actions/release-publish/README.md @@ -23,8 +23,10 @@ the title, the notes and the assets the caller chooses. It is the second half of `target` is the commit the release is made at: release-merge's `base-sha` output, or a branch name for a release a person merged by hand. The tag is made at that commit, whatever the branch points -to by the time this runs. [`templates/workflows/release-merge.yml`](../../../templates/workflows/release-merge.yml) -is a complete workflow around the two actions, ready to copy into a plugin repository. +to by the time this runs. [`templates/workflows/release-publish.yml`](../../../templates/workflows/release-publish.yml) +is a complete workflow around this action alone, for the hand-merged release, ready to copy into a +plugin repository; a repository that merges with release-merge adds this action to that workflow, +after its build, as above. ## What it does diff --git a/README.md b/README.md index f3fe5bb..b27f8b8 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ Each phase ends with something people can actually use. - [ ] Add `setup-node` for the npm crowd. - [x] Add `release-prep`: version bump, TBD replacement and changelog through pup and the changelogger action, committed to the release branch through the GitHub API. A caller to copy is in `templates/workflows/release-prep.yml`. - [x] Add `release-merge`: the release branch into the base branch, the base back into develop, each step safe to rerun and a conflict reported with a `MERGE_CONFLICT` line. A caller to copy is in `templates/workflows/release-merge.yml`. -- [x] Add `release-publish`: the GitHub release for a version at a commit, with a title, notes from a file and assets uploaded before the release is public, skipped when it already exists. The template above runs it after the build. +- [x] Add `release-publish`: the GitHub release for a version at a commit, with a title, notes from a file and assets uploaded before the release is public, skipped when it already exists. A caller to copy is in `templates/workflows/release-publish.yml`; a repository that merges with `release-merge` runs it in that workflow, after its build. - [ ] Turn every hard-coded repo name, slug, or branch pattern into an input. - [ ] Self-checks: actionlint everything, unit test anything with real logic. - [ ] Tag `v1.0.0`. diff --git a/templates/workflows/release-publish.yml b/templates/workflows/release-publish.yml new file mode 100644 index 0000000..f975f35 --- /dev/null +++ b/templates/workflows/release-publish.yml @@ -0,0 +1,63 @@ +name: Release Publish + +# Publishes the GitHub release for a version at the tip of a branch: the tag and the release in one +# step, with a title, the notes from a file in the checkout and assets. For a release a person merged +# by hand. A repository whose release is merged by the release-merge action runs release-publish in +# that same workflow instead, after its build, with `target: ${{ steps.merge.outputs.base-sha }}`. +# +# A file to copy into .github/workflows/ of a plugin repository, not a workflow to call. +# +# Run it locally: you cannot, it creates a tag and a release. Start it by hand with +# `gh workflow run release-publish.yml -f branch=release/M26.jigglypuff -f version=4.17.0`. + +on: + workflow_dispatch: + inputs: + branch: + description: 'Branch to publish at its tip, e.g. release/M26.jigglypuff' + required: true + type: string + version: + description: 'Version being released, e.g. 4.17.0. Becomes the tag' + required: true + type: string + title: + description: 'Release title. Empty: the version' + required: false + type: string + default: '' + +# One publish at a time for the repository: two runs for the same version would otherwise both pass +# the "no tag yet" check and race to create it. +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-24.04 + # A release that reaches users should wait for a person: give this environment required reviewers + # in the repository settings. + environment: live + permissions: + contents: write # the action creates the tag and the release + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.branch }} + persist-credentials: false + + # Build the zip to attach here, from the checkout, and write the notes file if the release + # notes come from the changelog rather than from GitHub's generated list. + + - uses: stellarwp/plugin-toolbox/.github/actions/release-publish@v1 + with: + version: ${{ inputs.version }} + target: ${{ inputs.branch }} + title: ${{ inputs.title }} + # notes-file: release-notes.md + # files: | + # build/my-plugin.zip From 1df131dde271ed91d4ccb5320c519f65ecb33688 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 17:46:19 -0300 Subject: [PATCH 03/14] Publish at the commit the template checked out, not at the branch's tip The release-publish template checked out the branch, invited a build from that checkout, and passed the branch name as target, which the action resolves to the branch's tip when it runs: a push between the checkout and the publish would tag a commit other than the one the assets came from. The template now records the checked-out commit and passes it. SOFT-5188 --- templates/workflows/release-publish.yml | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/templates/workflows/release-publish.yml b/templates/workflows/release-publish.yml index f975f35..02fc6e9 100644 --- a/templates/workflows/release-publish.yml +++ b/templates/workflows/release-publish.yml @@ -1,9 +1,10 @@ name: Release Publish -# Publishes the GitHub release for a version at the tip of a branch: the tag and the release in one -# step, with a title, the notes from a file in the checkout and assets. For a release a person merged -# by hand. A repository whose release is merged by the release-merge action runs release-publish in -# that same workflow instead, after its build, with `target: ${{ steps.merge.outputs.base-sha }}`. +# Publishes the GitHub release for a version at the commit the workflow checked out from a branch: +# the tag and the release in one step, with a title, the notes from a file in the checkout and +# assets built from that same checkout. For a release a person merged by hand. A repository whose +# release is merged by the release-merge action runs release-publish in that same workflow instead, +# after its build, with `target: ${{ steps.merge.outputs.base-sha }}`. # # A file to copy into .github/workflows/ of a plugin repository, not a workflow to call. # @@ -14,7 +15,7 @@ on: workflow_dispatch: inputs: branch: - description: 'Branch to publish at its tip, e.g. release/M26.jigglypuff' + description: 'Branch to publish, at the commit checked out below, e.g. release/M26.jigglypuff' required: true type: string version: @@ -50,13 +51,19 @@ jobs: ref: ${{ inputs.branch }} persist-credentials: false + # The commit to publish at: the one checked out, so the tag points at what the steps below + # build, even if the branch moves meanwhile. A branch name as `target` would be resolved to + # its tip when the action runs. + - id: head + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + # Build the zip to attach here, from the checkout, and write the notes file if the release # notes come from the changelog rather than from GitHub's generated list. - uses: stellarwp/plugin-toolbox/.github/actions/release-publish@v1 with: version: ${{ inputs.version }} - target: ${{ inputs.branch }} + target: ${{ steps.head.outputs.sha }} title: ${{ inputs.title }} # notes-file: release-notes.md # files: | From 3556a080e952c6400e3bb9dbcfbed5e423bbb341 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 17:53:53 -0300 Subject: [PATCH 04/14] Keep every waiting publish in the concurrency queue SOFT-5188 --- templates/workflows/release-publish.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/templates/workflows/release-publish.yml b/templates/workflows/release-publish.yml index 02fc6e9..e28a65e 100644 --- a/templates/workflows/release-publish.yml +++ b/templates/workflows/release-publish.yml @@ -29,10 +29,12 @@ on: default: '' # One publish at a time for the repository: two runs for the same version would otherwise both pass -# the "no tag yet" check and race to create it. +# the "no tag yet" check and race to create it. `queue: max` keeps every waiting run; the default +# keeps one and cancels it when another arrives. concurrency: group: ${{ github.workflow }} cancel-in-progress: false + queue: max permissions: contents: read From c721faa49d9dd36ec2c5d8e1fb3ea0e9523d0a3d Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 18:37:47 -0300 Subject: [PATCH 05/14] Back to the default concurrency queue in the publish template SOFT-5188 --- templates/workflows/release-publish.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/templates/workflows/release-publish.yml b/templates/workflows/release-publish.yml index e28a65e..02fc6e9 100644 --- a/templates/workflows/release-publish.yml +++ b/templates/workflows/release-publish.yml @@ -29,12 +29,10 @@ on: default: '' # One publish at a time for the repository: two runs for the same version would otherwise both pass -# the "no tag yet" check and race to create it. `queue: max` keeps every waiting run; the default -# keeps one and cancels it when another arrives. +# the "no tag yet" check and race to create it. concurrency: group: ${{ github.workflow }} cancel-in-progress: false - queue: max permissions: contents: read From 20513bf79f2dc69452c0cf0823972cf375c26f35 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 18:49:01 -0300 Subject: [PATCH 06/14] One caller template, release.yml: the deploy workflow the dashboard dispatches The per-action templates matched no repository: nobody merges without publishing, and a repository that only publishes does not go through the dashboard. release.yml, the name the dashboard looks for by default, runs release-merge, leaves room for the build, then release-publish at the merged commit. The publish-only shape stays as a snippet in release-publish's README. SOFT-5188 --- .github/actions/release-merge/README.md | 3 + .github/actions/release-publish/README.md | 30 +++++++-- README.md | 4 +- templates/workflows/release-publish.yml | 70 -------------------- templates/workflows/release.yml | 78 +++++++++++++++++++++++ 5 files changed, 107 insertions(+), 78 deletions(-) delete mode 100644 templates/workflows/release-publish.yml create mode 100644 templates/workflows/release.yml diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md index 7d69b63..7d50cde 100644 --- a/.github/actions/release-merge/README.md +++ b/.github/actions/release-merge/README.md @@ -33,6 +33,9 @@ It is the part of a deploy that is the same for every plugin repository, on a ba `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 diff --git a/.github/actions/release-publish/README.md b/.github/actions/release-publish/README.md index 35bf4e1..f08b67f 100644 --- a/.github/actions/release-publish/README.md +++ b/.github/actions/release-publish/README.md @@ -21,12 +21,30 @@ the title, the notes and the assets the caller chooses. It is the second half of build/my-plugin.zip ``` -`target` is the commit the release is made at: release-merge's `base-sha` output, or a branch name -for a release a person merged by hand. The tag is made at that commit, whatever the branch points -to by the time this runs. [`templates/workflows/release-publish.yml`](../../../templates/workflows/release-publish.yml) -is a complete workflow around this action alone, for the hand-merged release, ready to copy into a -plugin repository; a repository that merges with release-merge adds this action to that workflow, -after its build, as above. +`target` is the commit the release is made at: release-merge's `base-sha` output, as above, which is +also the commit the build between the two actions came from. 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 diff --git a/README.md b/README.md index b27f8b8..3dc4dbf 100644 --- a/README.md +++ b/README.md @@ -168,8 +168,8 @@ Each phase ends with something people can actually use. - [ ] Port `check-php-changes`, `smart-checkout`, `process-changelog`, `add-changelog`, `generate-pot` from TEC. - [ ] Add `setup-node` for the npm crowd. - [x] Add `release-prep`: version bump, TBD replacement and changelog through pup and the changelogger action, committed to the release branch through the GitHub API. A caller to copy is in `templates/workflows/release-prep.yml`. -- [x] Add `release-merge`: the release branch into the base branch, the base back into develop, each step safe to rerun and a conflict reported with a `MERGE_CONFLICT` line. A caller to copy is in `templates/workflows/release-merge.yml`. -- [x] Add `release-publish`: the GitHub release for a version at a commit, with a title, notes from a file and assets uploaded before the release is public, skipped when it already exists. A caller to copy is in `templates/workflows/release-publish.yml`; a repository that merges with `release-merge` runs it in that workflow, after its build. +- [x] Add `release-merge`: the release branch into the base branch, the base back into develop, each step safe to rerun and a conflict reported with a `MERGE_CONFLICT` line. +- [x] Add `release-publish`: the GitHub release for a version at a commit, with a title, notes from a file and assets uploaded before the release is public, skipped when it already exists. The complete deploy workflow to copy, both actions with the build between them, is `templates/workflows/release.yml`. - [ ] Turn every hard-coded repo name, slug, or branch pattern into an input. - [ ] Self-checks: actionlint everything, unit test anything with real logic. - [ ] Tag `v1.0.0`. diff --git a/templates/workflows/release-publish.yml b/templates/workflows/release-publish.yml deleted file mode 100644 index 02fc6e9..0000000 --- a/templates/workflows/release-publish.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: Release Publish - -# Publishes the GitHub release for a version at the commit the workflow checked out from a branch: -# the tag and the release in one step, with a title, the notes from a file in the checkout and -# assets built from that same checkout. For a release a person merged by hand. A repository whose -# release is merged by the release-merge action runs release-publish in that same workflow instead, -# after its build, with `target: ${{ steps.merge.outputs.base-sha }}`. -# -# A file to copy into .github/workflows/ of a plugin repository, not a workflow to call. -# -# Run it locally: you cannot, it creates a tag and a release. Start it by hand with -# `gh workflow run release-publish.yml -f branch=release/M26.jigglypuff -f version=4.17.0`. - -on: - workflow_dispatch: - inputs: - branch: - description: 'Branch to publish, at the commit checked out below, e.g. release/M26.jigglypuff' - required: true - type: string - version: - description: 'Version being released, e.g. 4.17.0. Becomes the tag' - required: true - type: string - title: - description: 'Release title. Empty: the version' - required: false - type: string - default: '' - -# One publish at a time for the repository: two runs for the same version would otherwise both pass -# the "no tag yet" check and race to create it. -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -permissions: - contents: read - -jobs: - publish: - runs-on: ubuntu-24.04 - # A release that reaches users should wait for a person: give this environment required reviewers - # in the repository settings. - environment: live - permissions: - contents: write # the action creates the tag and the release - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ inputs.branch }} - persist-credentials: false - - # The commit to publish at: the one checked out, so the tag points at what the steps below - # build, even if the branch moves meanwhile. A branch name as `target` would be resolved to - # its tip when the action runs. - - id: head - run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - - # Build the zip to attach here, from the checkout, and write the notes file if the release - # notes come from the changelog rather than from GitHub's generated list. - - - uses: stellarwp/plugin-toolbox/.github/actions/release-publish@v1 - with: - version: ${{ inputs.version }} - target: ${{ steps.head.outputs.sha }} - title: ${{ inputs.title }} - # notes-file: release-notes.md - # files: | - # build/my-plugin.zip diff --git a/templates/workflows/release.yml b/templates/workflows/release.yml new file mode 100644 index 0000000..f045670 --- /dev/null +++ b/templates/workflows/release.yml @@ -0,0 +1,78 @@ +name: Release + +# The deploy workflow the release dashboard dispatches, under the name it looks for by default. It +# merges the release and publishes it on GitHub: the release branch into the base branch (`main` +# here, `master` in some repositories), the base branch back into develop, then the GitHub release +# for the version at the merged commit, with the build attached. The build itself is the plugin's: +# add it between the two actions, on the checkout release-merge leaves on the base branch, and +# attach its result with `files`. +# +# A file to copy into .github/workflows/ of a plugin repository, not a workflow to call. Rename the +# base branch below where it is `master`. +# +# The inputs are what the release dashboard sends for the deploy step: `branch` and `version`. +# +# Run it locally: you cannot, it pushes to two branches and publishes a release. Start it by hand with +# `gh workflow run release.yml -f branch=release/M26.jigglypuff -f version=4.17.0`. + +on: + workflow_dispatch: + inputs: + branch: + description: 'Release branch to merge, e.g. release/M26.jigglypuff' + required: true + type: string + version: + description: 'Version being released, e.g. 4.17.0' + required: true + type: string + +# One deploy at a time for the whole repository, not one per release. Two releases prepared with the +# same version would otherwise merge side by side, and the second would land its changes under a +# version the first has just published. Run one after the other, the second finds the tag published +# and release-merge refuses it before merging. A repository that also deploys from another workflow +# (an `on: release` workflow, for instance) gives both the same group. GitHub keeps one run waiting +# per group and cancels it when another arrives; a cancelled deploy is started again. +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + release: + runs-on: ubuntu-24.04 + # A release that reaches users should wait for a person: give this environment required reviewers + # in the repository settings, and let its branch policy accept the release branches the dashboard + # dispatches on. + environment: live + permissions: + contents: write # release-merge pushes the merges, release-publish creates the tag and the release + steps: + - uses: actions/checkout@v6 + with: + # release-merge fetches the branches it merges and pushes them with the token it is given, so + # the checkout keeps no credentials in .git/config for a later step to find. + persist-credentials: false + + - uses: stellarwp/plugin-toolbox/.github/actions/release-merge@v1 + id: merge + with: + branch: ${{ inputs.branch }} + version: ${{ inputs.version }} + # base-branch: master + # develop-branch: '' # for a repository without a develop branch + + # The checkout is now on the merged base branch: build and deploy from here, so the release + # below is published only once the build exists, with it attached. + + - uses: stellarwp/plugin-toolbox/.github/actions/release-publish@v1 + id: release + with: + version: ${{ inputs.version }} + target: ${{ steps.merge.outputs.base-sha }} + # title: Maintenance Release ${{ inputs.version }} + # notes-file: release-notes.md + # files: | + # build/my-plugin.zip From 0de5045a8fd8ba2c9e32437de796dbd3089a764c Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 18:55:09 -0300 Subject: [PATCH 07/14] Refuse a release run started on another branch, and say which releases github.token cannot create The environment's branch policy protects the branch a run is started on, not the branch input, so a run started on an allowed branch could merge and publish any other. The template's first step fails when the two differ; the dashboard starts the run on the release branch, and a hand dispatch passes --ref. A failing step rather than an if on the job, since a skipped job reads as a success to the dispatcher. The release-publish README also says that a release at a commit that changes a workflow file relative to the default branch needs a token allowed to modify workflows, which github.token cannot be; after release-merge the published commit is the base branch's tip, so the case is the hand-merged publish only. SOFT-5188 --- .github/actions/release-publish/README.md | 8 ++++++++ templates/workflows/release.yml | 18 ++++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/.github/actions/release-publish/README.md b/.github/actions/release-publish/README.md index f08b67f..efa25cc 100644 --- a/.github/actions/release-publish/README.md +++ b/.github/actions/release-publish/README.md @@ -100,6 +100,14 @@ rules allow it to create a tag: a tag ruleset should block deletion and updates, 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 diff --git a/templates/workflows/release.yml b/templates/workflows/release.yml index f045670..ce618c0 100644 --- a/templates/workflows/release.yml +++ b/templates/workflows/release.yml @@ -12,8 +12,9 @@ name: Release # # The inputs are what the release dashboard sends for the deploy step: `branch` and `version`. # -# Run it locally: you cannot, it pushes to two branches and publishes a release. Start it by hand with -# `gh workflow run release.yml -f branch=release/M26.jigglypuff -f version=4.17.0`. +# Run it locally: you cannot, it pushes to two branches and publishes a release. Start it by hand on +# the release branch, as the dashboard does: +# `gh workflow run release.yml --ref release/M26.jigglypuff -f branch=release/M26.jigglypuff -f version=4.17.0`. on: workflow_dispatch: @@ -50,6 +51,19 @@ jobs: permissions: contents: write # release-merge pushes the merges, release-publish creates the tag and the release steps: + # The environment's branch policy protects the branch the run was started on, not the input. + # The two have to be the same branch, so the policy covers what is actually merged and + # published. A failing step, not an `if:` on the job: a skipped job reads as a success to the + # tool that dispatched the run. + - name: Check that the run is on the release branch it merges + env: + BRANCH: ${{ inputs.branch }} + run: | + if [ "$BRANCH" != "$GITHUB_REF_NAME" ]; then + echo "::error::This run is on '$GITHUB_REF_NAME' but was asked to release '$BRANCH'. Start it on the release branch (the dashboard does; by hand, pass --ref)." + exit 1 + fi + - uses: actions/checkout@v6 with: # release-merge fetches the branches it merges and pushes them with the token it is given, so From 399c7532d1925161374bf82e83a179671dfe08d9 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Tue, 3 Nov 2026 15:26:49 -0300 Subject: [PATCH 08/14] Check out the merged commit after release-merge, for the build release-merge now merges through the GitHub API and touches no checkout, so the template checks out base-sha after it, where the build runs, and needs no checkout before it. release-publish's README shows the same chain. --- .github/actions/release-publish/README.md | 8 ++++++-- templates/workflows/release.yml | 21 ++++++++++----------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/.github/actions/release-publish/README.md b/.github/actions/release-publish/README.md index efa25cc..a795355 100644 --- a/.github/actions/release-publish/README.md +++ b/.github/actions/release-publish/README.md @@ -11,7 +11,11 @@ the title, the notes and the assets the caller chooses. It is the second half of branch: ${{ inputs.branch }} version: ${{ inputs.version }} -# Build the plugin here, from the checkout the merge left on the base branch. +- 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: @@ -22,7 +26,7 @@ the title, the notes and the assets the caller chooses. It is the second half of ``` `target` is the commit the release is made at: release-merge's `base-sha` output, as above, which is -also the commit the build between the two actions came from. The tag is made at that commit, +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. diff --git a/templates/workflows/release.yml b/templates/workflows/release.yml index ce618c0..93ca2d9 100644 --- a/templates/workflows/release.yml +++ b/templates/workflows/release.yml @@ -4,8 +4,8 @@ name: Release # merges the release and publishes it on GitHub: the release branch into the base branch (`main` # here, `master` in some repositories), the base branch back into develop, then the GitHub release # for the version at the merged commit, with the build attached. The build itself is the plugin's: -# add it between the two actions, on the checkout release-merge leaves on the base branch, and -# attach its result with `files`. +# add it between the checkout of the merged commit and release-publish, and attach its result with +# `files`. # # A file to copy into .github/workflows/ of a plugin repository, not a workflow to call. Rename the # base branch below where it is `master`. @@ -49,7 +49,7 @@ jobs: # dispatches on. environment: live permissions: - contents: write # release-merge pushes the merges, release-publish creates the tag and the release + contents: write # release-merge writes the merges, release-publish creates the tag and the release steps: # The environment's branch policy protects the branch the run was started on, not the input. # The two have to be the same branch, so the policy covers what is actually merged and @@ -64,12 +64,7 @@ jobs: exit 1 fi - - uses: actions/checkout@v6 - with: - # release-merge fetches the branches it merges and pushes them with the token it is given, so - # the checkout keeps no credentials in .git/config for a later step to find. - persist-credentials: false - + # The merges go through the GitHub API: no checkout is needed before this step. - uses: stellarwp/plugin-toolbox/.github/actions/release-merge@v1 id: merge with: @@ -78,8 +73,12 @@ jobs: # base-branch: master # develop-branch: '' # for a repository without a develop branch - # The checkout is now on the merged base branch: build and deploy from here, so the release - # below is published only once the build exists, with it attached. + # The merged commit, the one the release is published at: build and deploy from it, so the + # release below is published only once the build exists, with it attached. + - uses: actions/checkout@v6 + with: + ref: ${{ steps.merge.outputs.base-sha }} + persist-credentials: false - uses: stellarwp/plugin-toolbox/.github/actions/release-publish@v1 id: release From 37b215df18255dd4712939b9e6e4773b795d9225 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Wed, 4 Nov 2026 15:31:59 -0300 Subject: [PATCH 09/14] Start the generated notes at the published version right below the one released generateReleaseNotes was called without previous_tag_name, so GitHub chose where the notes start, and its choice is not documented: a hotfix on an older line, 2.1.38.3 after 2.1.46 is out, could get the changes since 2.1.46. The releases are listed and the highest published version below the one released is passed as previous_tag_name; drafts and tags that are not plain versions are skipped. With nothing below, the start is left to GitHub as before. --- .github/actions/release-publish/README.md | 5 +- .../release-publish/release-publish.js | 66 +++++++++++++++- .../release-publish/release-publish.test.js | 77 +++++++++++++++++-- 3 files changed, 139 insertions(+), 9 deletions(-) diff --git a/.github/actions/release-publish/README.md b/.github/actions/release-publish/README.md index a795355..d29e7c1 100644 --- a/.github/actions/release-publish/README.md +++ b/.github/actions/release-publish/README.md @@ -64,7 +64,10 @@ In this order, stopping at the first failure: 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. + 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**, 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 diff --git a/.github/actions/release-publish/release-publish.js b/.github/actions/release-publish/release-publish.js index a02f589..48a53d9 100644 --- a/.github/actions/release-publish/release-publish.js +++ b/.github/actions/release-publish/release-publish.js @@ -204,6 +204,50 @@ async function latestReleaseTag({ github, owner, repo }) { } } +/** + * 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} The releases as the API lists them, drafts included. + */ +async function listReleases({ github, owner, repo }) { + try { + return await github.paginate(github.rest.repos.listReleases, { owner, repo, per_page: 100 }) + } catch (error) { + throw new Error(`Listing the releases failed (HTTP ${error.status}): ${error.message}. ${NOTHING}`) + } +} + +/** + * The published version right below the one being released: where its generated notes start. + * + * GitHub's own choice of a previous tag is not documented, so for a hotfix on an older line + * (`2.1.38.3` after `2.1.46` is out) the notes could cover the changes since `2.1.46`. Choosing it + * here makes the range the one the version number says: the highest published version below this + * one, `2.1.38.2`. Drafts and tags that are not plain versions are skipped. + * + * @param {string} version The version being released. + * @param {object[]} releases The releases listReleases returned. + * + * @returns {string|null} The tag to start the notes from, or null when no release is below. + */ +function previousVersion(version, releases) { + return ( + releases + .filter((release) => !release.draft && STELLARWP_VERSION_PATTERN.test(release.tag_name)) + .map((release) => release.tag_name) + .filter((tag) => compareVersions(tag, version) < 0) + .sort(compareVersions) + .at(-1) ?? null + ) +} + /** * Whether the new release takes "Latest": yes, unless the current latest is a higher version, as * when a hotfix goes out on an older line after a newer version is released. A latest whose tag is @@ -276,9 +320,24 @@ async function runPublish({ github, core, env, cwd = process.cwd() }) { core.info(`${version} is below the current latest release, so it is published without taking "Latest".`) } - const body = - notes ?? - (await github.rest.repos.generateReleaseNotes({ owner, repo, tag_name: version, target_commitish: sha })).data.body + let body = notes + if (body === null) { + const previous = previousVersion(version, await listReleases({ github, owner, repo })) + core.info( + previous + ? `The notes cover the changes since ${previous}.` + : 'No published release is below this version: GitHub chooses where the notes start.' + ) + body = ( + await github.rest.repos.generateReleaseNotes({ + owner, + repo, + tag_name: version, + target_commitish: sha, + ...(previous ? { previous_tag_name: previous } : {}), + }) + ).data.body + } let release if (files.length === 0) { @@ -350,4 +409,5 @@ module.exports = { readNotesFile, readFiles, makeLatest, + previousVersion, } diff --git a/tests/actions/release-publish/release-publish.test.js b/tests/actions/release-publish/release-publish.test.js index 4f2a657..476c587 100644 --- a/tests/actions/release-publish/release-publish.test.js +++ b/tests/actions/release-publish/release-publish.test.js @@ -15,6 +15,7 @@ const { readNotesFile, readFiles, makeLatest, + previousVersion, } = require('../../../.github/actions/release-publish/release-publish.js') const { coreWithOutputs, octokitFor } = require('../../support/actions.js') @@ -29,16 +30,24 @@ const API = { branch: '/repos/stellarwp/plugin/branches/main', notes: '/repos/stellarwp/plugin/releases/generate-notes', releases: '/repos/stellarwp/plugin/releases', + list: '/repos/stellarwp/plugin/releases?per_page=100', release: '/repos/stellarwp/plugin/releases/77', assets: /^https:\/\/uploads\.github\.com\/repos\/stellarwp\/plugin\/releases\/77\/assets\?name=/, } const RELEASE_URL = 'https://github.com/stellarwp/plugin/releases/tag/4.17.0' +/** The releases the repository has, as the list endpoint sends them: 4.16.0 is the latest. */ +const RELEASES = [ + { id: 60, tag_name: '4.16.0', draft: false }, + { id: 59, tag_name: '4.15.2', draft: false }, +] + /** The rules of the common case: no tag, no release yet, a lower latest, notes generated. */ const FRESH = [ { method: 'POST', path: API.notes, body: { name: '4.17.0', body: '## What changed\n* Fix (#12)' } }, { method: 'GET', path: API.latest, body: { tag_name: '4.16.0' } }, + { method: 'GET', path: API.list, body: RELEASES }, ] /** @@ -166,6 +175,29 @@ describe('release-publish', () => { }) }) + describe('previousVersion', () => { + it('picks the highest published version below the one released, for a hotfix on an older line too', () => { + const releases = [ + { tag_name: '2.1.46', draft: false }, + { tag_name: '2.1.38.2', draft: false }, + { tag_name: '2.1.38.1', draft: false }, + { tag_name: '2.1.38.3', draft: true }, + { tag_name: 'v2.0.0', draft: false }, + { tag_name: '2.1.40', draft: false }, + ] + + assert.strictEqual(previousVersion('2.1.38.3', releases), '2.1.38.2') + assert.strictEqual(previousVersion('2.1.47', releases), '2.1.46') + assert.strictEqual(previousVersion('2.1.41', releases), '2.1.40') + }) + + it('finds nothing below the first release, or among drafts and tags that are not plain versions', () => { + assert.strictEqual(previousVersion('1.0.0', [{ tag_name: '1.1.0', draft: false }]), null) + assert.strictEqual(previousVersion('2.0.0', [{ tag_name: '1.9.0', draft: true }, { tag_name: 'v1.8.0', draft: false }]), null) + assert.strictEqual(previousVersion('2.0.0', []), null) + }) + }) + describe('makeLatest', () => { it('keeps "Latest" on a newer release when an older line gets a hotfix', () => { assert.strictEqual(makeLatest('4.17.0', null), 'true') @@ -188,11 +220,14 @@ describe('release-publish', () => { `GET ${API.releaseByTag}`, `GET ${API.tag}`, `GET ${API.latest}`, + `GET ${API.list}`, `POST ${API.notes}`, `POST ${API.releases}`, ]) - assert.deepStrictEqual(requests[3].body, { tag_name: '4.17.0', target_commitish: SHA }) - assert.deepStrictEqual(requests[4].body, { + // The notes start at the published version right below this one, chosen here rather than + // left to GitHub. + assert.deepStrictEqual(requests[4].body, { tag_name: '4.17.0', target_commitish: SHA, previous_tag_name: '4.16.0' }) + assert.deepStrictEqual(requests[5].body, { tag_name: '4.17.0', target_commitish: SHA, name: '4.17.0', @@ -203,6 +238,33 @@ describe('release-publish', () => { assert.match(summary(), /Published release 4\.17\.0 at `0123456`/) }) + it('leaves the start of the notes to GitHub when no published release is below the version', async (t) => { + const { github, requests } = await octokitFor([ + { method: 'POST', path: API.notes, body: { body: 'first' } }, + { method: 'GET', path: API.latest, status: 404, body: { message: 'Not Found' } }, + { method: 'GET', path: API.list, body: [{ id: 1, tag_name: '4.17.0', draft: true }] }, + { method: 'POST', path: API.releases, status: 201, body: { id: 77, html_url: RELEASE_URL } }, + ]) + const { core, logged } = await coreWithOutputs(t) + + await runPublish({ github, core, env: publishEnv(), cwd: checkoutWith() }) + + const notes = requests.find((request) => request.path === API.notes) + assert.deepStrictEqual(notes.body, { tag_name: '4.17.0', target_commitish: SHA }) + assert.match(logged(), /No published release is below this version/) + }) + + it('fails when the releases cannot be listed, rather than guessing where the notes start', async (t) => { + const { github, requests } = await octokitFor([ + { method: 'GET', path: API.latest, body: { tag_name: '4.16.0' } }, + { method: 'GET', path: API.list, status: 500, body: { message: 'boom' } }, + ]) + const { core } = await coreWithOutputs(t) + + await assert.rejects(runPublish({ github, core, env: publishEnv(), cwd: checkoutWith() }), /Listing the releases failed \(HTTP 500\).*Nothing was published/s) + assert.ok(!requested(requests).some((line) => line.startsWith('POST')), 'nothing is created') + }) + it('publishes at the tip of a branch given as target, with the title and the notes file', async (t) => { const cwd = checkoutWith({ 'notes.md': '= 4.17.0 =\n* Fixed a thing\n' }) const { github, requests } = await octokitFor([ @@ -234,6 +296,7 @@ describe('release-publish', () => { const { github, requests } = await octokitFor([ { method: 'POST', path: API.notes, body: { body: 'notes' } }, { method: 'GET', path: API.latest, body: { tag_name: '4.18.0' } }, + { method: 'GET', path: API.list, body: [{ id: 61, tag_name: '4.18.0', draft: false }, ...RELEASES] }, { method: 'POST', path: API.releases, status: 201, body: { id: 77, html_url: RELEASE_URL } }, ]) const { core, logged } = await coreWithOutputs(t) @@ -242,11 +305,14 @@ describe('release-publish', () => { assert.strictEqual(requests.at(-1).body.make_latest, 'false') assert.match(logged(), /below the current latest release/) + // The notes start at the release below the hotfix, not at the newer 4.18.0. + assert.strictEqual(requests.find((request) => request.path === API.notes).body.previous_tag_name, '4.16.0') }) it('takes "Latest" when the repository has no release yet', async (t) => { const { github, requests } = await octokitFor([ { method: 'POST', path: API.notes, body: { body: 'notes' } }, + { method: 'GET', path: API.list, body: [] }, { method: 'POST', path: API.releases, status: 201, body: { id: 77, html_url: RELEASE_URL } }, ]) const { core } = await coreWithOutputs(t) @@ -272,6 +338,7 @@ describe('release-publish', () => { `GET ${API.releaseByTag}`, `GET ${API.tag}`, `GET ${API.latest}`, + `GET ${API.list}`, `POST ${API.notes}`, `POST ${API.releases}`, 'POST uploads:/repos/stellarwp/plugin/releases/77/assets?name=plugin.zip', @@ -279,15 +346,15 @@ describe('release-publish', () => { `PATCH ${API.release}`, ]) // Created as a draft, with no make_latest yet: that is decided when it is published. - assert.deepStrictEqual(requests[4].body, { + assert.deepStrictEqual(requests[5].body, { tag_name: '4.17.0', target_commitish: SHA, name: '4.17.0', body: '## What changed\n* Fix (#12)', draft: true, }) - assert.strictEqual(String(requests[5].body), 'PK zip bytes') - assert.deepStrictEqual(requests[7].body, { draft: false, make_latest: 'true' }) + assert.strictEqual(String(requests[6].body), 'PK zip bytes') + assert.deepStrictEqual(requests[8].body, { draft: false, make_latest: 'true' }) assert.deepStrictEqual(outputs(), { published: 'true', 'release-url': RELEASE_URL, sha: SHA }) assert.match(logged(), /Attached plugin\.zip.*Attached plugin\.pot/s) assert.match(summary(), /with `plugin\.zip`, `plugin\.pot`/) From 139ff1d3080c5392e9db9c731ca464c7d2843abf Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Wed, 4 Nov 2026 15:33:26 -0300 Subject: [PATCH 10/14] Delete the draft when publishing it fails, and say so only when it was deleted The draft was deleted only when an upload failed. When the call that publishes it failed, which is what a tag ruleset that blocks the tag produces, the draft stayed, and a rerun found no release by the tag and made a second one. The publish is now undone the same way. The error says the draft was deleted only when the delete succeeded; otherwise it says where to delete it by hand. A draft of the version left behind by an earlier run, cancelled while uploading, is deleted before publishing. --- .github/actions/release-publish/README.md | 13 +++- .../release-publish/release-publish.js | 61 ++++++++++++++-- .../release-publish/release-publish.test.js | 73 +++++++++++++++++++ 3 files changed, 137 insertions(+), 10 deletions(-) diff --git a/.github/actions/release-publish/README.md b/.github/actions/release-publish/README.md index d29e7c1..c45c71b 100644 --- a/.github/actions/release-publish/README.md +++ b/.github/actions/release-publish/README.md @@ -69,9 +69,11 @@ In this order, stopping at the first failure: 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**, 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. + 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 @@ -100,6 +102,11 @@ attached because the run died between the publish and a later step stays missing 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 of the version 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. 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 diff --git a/.github/actions/release-publish/release-publish.js b/.github/actions/release-publish/release-publish.js index 48a53d9..13f8e79 100644 --- a/.github/actions/release-publish/release-publish.js +++ b/.github/actions/release-publish/release-publish.js @@ -248,6 +248,31 @@ function previousVersion(version, releases) { ) } +/** + * Deletes a draft release, reporting whether it worked rather than assuming so. + * + * @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 {object} options.draft The draft, as the API returned it. + * + * @returns {Promise} A sentence for the error message: that the draft was deleted, or that + * it was not and where to delete it by hand. + */ +async function deleteDraft({ github, owner, repo, draft }) { + try { + await github.rest.repos.deleteRelease({ owner, repo, release_id: draft.id }) + + return 'The draft was deleted and no tag was made.' + } catch (error) { + return ( + `The draft could not be deleted (HTTP ${error.status}): delete it by hand at ${draft.html_url}. ` + + 'No tag was made.' + ) + } +} + /** * Whether the new release takes "Latest": yes, unless the current latest is a higher version, as * when a hotfix goes out on an older line after a newer version is released. A latest whose tag is @@ -320,9 +345,25 @@ async function runPublish({ github, core, env, cwd = process.cwd() }) { core.info(`${version} is below the current latest release, so it is published without taking "Latest".`) } + // A draft of this version left by a run that was cancelled while uploading, or whose publish + // failed before the draft could be deleted. getReleaseByTag does not return drafts, so a rerun + // would otherwise leave it there and add a second one. + const releases = await listReleases({ github, owner, repo }) + for (const stale of releases.filter((release) => release.draft && release.tag_name === version)) { + try { + await github.rest.repos.deleteRelease({ owner, repo, release_id: stale.id }) + core.info(`Deleted a draft of ${version} left behind by an earlier run (${stale.html_url}).`) + } catch (error) { + core.warning( + `A draft of ${version} left behind by an earlier run could not be deleted (HTTP ${error.status}): ` + + `delete it by hand at ${stale.html_url}.` + ) + } + } + let body = notes if (body === null) { - const previous = previousVersion(version, await listReleases({ github, owner, repo })) + const previous = previousVersion(version, releases) core.info( previous ? `The notes cover the changes since ${previous}.` @@ -378,18 +419,24 @@ async function runPublish({ github, core, env, cwd = process.cwd() }) { headers: { 'content-type': 'application/octet-stream', 'content-length': asset.size }, }) } catch (error) { - await github.rest.repos.deleteRelease({ owner, repo, release_id: draft.id }).catch(() => {}) + const deleted = await deleteDraft({ github, owner, repo, draft }) throw new Error( - `Uploading ${asset.name} to the release failed (HTTP ${error.status}): ${error.message}. ` + - `The draft was deleted and no tag was made. ${NOTHING}` + `Uploading ${asset.name} to the release failed (HTTP ${error.status}): ${error.message}. ${deleted} ${NOTHING}` ) } core.info(`Attached ${asset.name}.`) } - release = ( - await github.rest.repos.updateRelease({ owner, repo, release_id: draft.id, draft: false, make_latest: latest }) - ).data + // Publishing the draft is what makes the tag, so this is the call a tag ruleset refuses. The + // draft is deleted here too, or a rerun would find none by the tag and leave it behind. + try { + release = ( + await github.rest.repos.updateRelease({ owner, repo, release_id: draft.id, draft: false, make_latest: latest }) + ).data + } catch (error) { + const deleted = await deleteDraft({ github, owner, repo, draft }) + throw new Error(`Publishing the draft failed (HTTP ${error.status}): ${error.message}. ${deleted} ${NOTHING}`) + } } core.info(`Published ${version} at ${sha.slice(0, 7)}: ${release.html_url}`) diff --git a/tests/actions/release-publish/release-publish.test.js b/tests/actions/release-publish/release-publish.test.js index 476c587..e2ef1ca 100644 --- a/tests/actions/release-publish/release-publish.test.js +++ b/tests/actions/release-publish/release-publish.test.js @@ -270,6 +270,7 @@ describe('release-publish', () => { const { github, requests } = await octokitFor([ { method: 'GET', path: API.branch, body: { commit: { sha: SHA } } }, { method: 'GET', path: API.latest, body: { tag_name: '4.16.0' } }, + { method: 'GET', path: API.list, body: RELEASES }, { method: 'POST', path: API.releases, status: 201, body: { id: 77, html_url: RELEASE_URL } }, ]) const { core } = await coreWithOutputs(t) @@ -379,6 +380,78 @@ describe('release-publish', () => { assert.deepStrictEqual(outputs(), {}) }) + it('deletes the draft when publishing it fails, as when a tag ruleset refuses the tag', async (t) => { + const cwd = checkoutWith({ 'build/plugin.zip': 'zip' }) + const { github, requests } = await octokitFor([ + ...FRESH, + { method: 'POST', path: API.releases, status: 201, body: { id: 77, html_url: RELEASE_URL } }, + { method: 'POST', path: API.assets, status: 201, body: { id: 1 } }, + { method: 'PATCH', path: API.release, status: 422, body: { message: 'Tag creation is blocked by a ruleset' } }, + { method: 'DELETE', path: API.release, status: 204, body: {} }, + ]) + const { core, outputs } = await coreWithOutputs(t) + + await assert.rejects( + runPublish({ github, core, env: publishEnv({ INPUT_FILES: 'build/plugin.zip' }), cwd }), + /Publishing the draft failed \(HTTP 422\): Tag creation is blocked.*The draft was deleted and no tag was made.*Nothing was published/s + ) + + assert.deepStrictEqual(requested(requests).slice(-2), [`PATCH ${API.release}`, `DELETE ${API.release}`]) + assert.deepStrictEqual(outputs(), {}) + }) + + it('says where to delete the draft by hand when deleting it fails too', async (t) => { + const cwd = checkoutWith({ 'build/plugin.zip': 'zip' }) + const { github } = await octokitFor([ + ...FRESH, + { method: 'POST', path: API.releases, status: 201, body: { id: 77, html_url: RELEASE_URL } }, + { method: 'POST', path: API.assets, status: 502, body: { message: 'bad gateway' } }, + { method: 'DELETE', path: API.release, status: 500, body: { message: 'boom' } }, + ]) + const { core } = await coreWithOutputs(t) + + await assert.rejects( + runPublish({ github, core, env: publishEnv({ INPUT_FILES: 'build/plugin.zip' }), cwd }), + /Uploading plugin\.zip.*The draft could not be deleted \(HTTP 500\): delete it by hand at https:\/\/github\.com\/stellarwp\/plugin\/releases\/tag\/4\.17\.0\. No tag was made/s + ) + }) + + it('deletes a draft of the version left behind by an earlier run before publishing', async (t) => { + // A run cancelled while uploading leaves its draft; getReleaseByTag does not return drafts. + const stale = { id: 70, tag_name: '4.17.0', draft: true, html_url: 'https://github.com/stellarwp/plugin/releases/untagged-abc' } + const { github, requests } = await octokitFor([ + { method: 'POST', path: API.notes, body: { body: 'notes' } }, + { method: 'GET', path: API.latest, body: { tag_name: '4.16.0' } }, + { method: 'GET', path: API.list, body: [stale, ...RELEASES] }, + { method: 'DELETE', path: '/repos/stellarwp/plugin/releases/70', status: 204, body: {} }, + { method: 'POST', path: API.releases, status: 201, body: { id: 77, html_url: RELEASE_URL } }, + ]) + const { core, outputs, logged } = await coreWithOutputs(t) + + await runPublish({ github, core, env: publishEnv(), cwd: checkoutWith() }) + + assert.deepStrictEqual(requested(requests).slice(4), ['DELETE /repos/stellarwp/plugin/releases/70', `POST ${API.notes}`, `POST ${API.releases}`]) + assert.match(logged(), /Deleted a draft of 4\.17\.0 left behind by an earlier run/) + assert.strictEqual(outputs().published, 'true') + }) + + it('warns and goes on when a draft left behind cannot be deleted', async (t) => { + const stale = { id: 70, tag_name: '4.17.0', draft: true, html_url: 'https://github.com/stellarwp/plugin/releases/untagged-abc' } + const { github } = await octokitFor([ + { method: 'POST', path: API.notes, body: { body: 'notes' } }, + { method: 'GET', path: API.latest, body: { tag_name: '4.16.0' } }, + { method: 'GET', path: API.list, body: [stale] }, + { method: 'DELETE', path: '/repos/stellarwp/plugin/releases/70', status: 403, body: { message: 'forbidden' } }, + { method: 'POST', path: API.releases, status: 201, body: { id: 77, html_url: RELEASE_URL } }, + ]) + const { core, outputs, logged } = await coreWithOutputs(t) + + await runPublish({ github, core, env: publishEnv(), cwd: checkoutWith() }) + + assert.match(logged(), /::warning::A draft of 4\.17\.0 left behind by an earlier run could not be deleted \(HTTP 403\): delete it by hand at https:\/\/github\.com\/stellarwp\/plugin\/releases\/untagged-abc/) + assert.strictEqual(outputs().published, 'true') + }) + it('refuses a missing file before creating anything', async (t) => { const { github, requests } = await octokitFor(FRESH) const { core } = await coreWithOutputs(t) From 637c3b24843299307a31f41a343a98fbfeaf10d5 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Wed, 4 Nov 2026 15:35:15 -0300 Subject: [PATCH 11/14] Read the tag again where a tag made meanwhile would take the release GitHub ignores target_commitish when the tag already exists, so a tag named after the version that someone creates between the first check and the publish attaches the release to that commit, and the action reported the commit it asked for. With files, the tag is read before the draft is published: one that appeared during the uploads deletes the draft and fails, naming its commit. Without files, it is read right after the release is created: one that points elsewhere fails naming the commit the release is at and its URL, since the release is public by then. The README asks the publish-only workflow for the template's concurrency group. --- .github/actions/release-publish/README.md | 17 +++++ .../release-publish/release-publish.js | 26 ++++++- .../release-publish/release-publish.test.js | 70 +++++++++++++++++-- 3 files changed, 107 insertions(+), 6 deletions(-) diff --git a/.github/actions/release-publish/README.md b/.github/actions/release-publish/README.md index c45c71b..0b07050 100644 --- a/.github/actions/release-publish/README.md +++ b/.github/actions/release-publish/README.md @@ -50,6 +50,10 @@ action runs, after the checkout): 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: @@ -94,6 +98,19 @@ In this order, stopping at the first failure: | `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 diff --git a/.github/actions/release-publish/release-publish.js b/.github/actions/release-publish/release-publish.js index 13f8e79..439206f 100644 --- a/.github/actions/release-publish/release-publish.js +++ b/.github/actions/release-publish/release-publish.js @@ -21,7 +21,7 @@ const fs = require('node:fs') const path = require('node:path') const { STELLARWP_VERSION_PATTERN, compareVersions, validateVersion: validateReleaseVersion } = require('../../lib/versions.js') -const { readRepository, isNotFound, tagExists } = require('../../lib/github.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.' @@ -380,6 +380,11 @@ async function runPublish({ github, core, env, cwd = process.cwd() }) { ).data.body } + // GitHub ignores target_commitish when the tag already exists, so a tag named after the version + // that someone creates between the check above and the publish attaches the release to that + // commit. The tag is read again at the point it would matter: here, after the release is public, + // the failure names the commit the release is at; with files, before the draft is published, + // where the draft can still be deleted. let release if (files.length === 0) { release = ( @@ -393,6 +398,16 @@ async function runPublish({ github, core, env, cwd = process.cwd() }) { make_latest: latest, }) ).data + + const tagSha = await readTag({ github, owner, repo, version, what: `The release is public at ${release.html_url}.` }) + if (tagSha !== null && tagSha !== sha) { + await core.summary.addRaw(`Release ${version} was published at \`${tagSha.slice(0, 7)}\`, not at \`${sha.slice(0, 7)}\`: ${release.html_url}`, true).write() + throw new Error( + `The tag ${version} was created at ${tagSha} between the check and the publish, and the release is ` + + `attached to it, not to ${sha}. The release is public at ${release.html_url}: delete it and the tag, or ` + + 'release another version.' + ) + } } else { // A draft makes no tag, so a failed upload can be undone by deleting it, and the release is // never public without its files. @@ -427,6 +442,15 @@ async function runPublish({ github, core, env, cwd = process.cwd() }) { core.info(`Attached ${asset.name}.`) } + const tagSha = await readTag({ github, owner, repo, version, what: NOTHING }) + if (tagSha !== null) { + const deleted = await deleteDraft({ github, owner, repo, draft }) + throw new Error( + `The tag ${version} was created at ${tagSha} while the files were uploading, so publishing the draft ` + + `would release that commit, not ${sha}. ${deleted} Delete the tag or release another version. ${NOTHING}` + ) + } + // Publishing the draft is what makes the tag, so this is the call a tag ruleset refuses. The // draft is deleted here too, or a rerun would find none by the tag and leave it behind. try { diff --git a/tests/actions/release-publish/release-publish.test.js b/tests/actions/release-publish/release-publish.test.js index e2ef1ca..179f69f 100644 --- a/tests/actions/release-publish/release-publish.test.js +++ b/tests/actions/release-publish/release-publish.test.js @@ -223,6 +223,8 @@ describe('release-publish', () => { `GET ${API.list}`, `POST ${API.notes}`, `POST ${API.releases}`, + // The tag, read again once the release exists: it has to point at the commit asked for. + `GET ${API.tag}`, ]) // The notes start at the published version right below this one, chosen here rather than // left to GitHub. @@ -284,7 +286,7 @@ describe('release-publish', () => { // No generated notes were asked for: the file is the body. assert.ok(!requested(requests).includes(`POST ${API.notes}`)) - assert.deepStrictEqual(requests.at(-1).body, { + assert.deepStrictEqual(requests.find((request) => request.method === 'POST' && request.path === API.releases).body, { tag_name: '4.17.0', target_commitish: SHA, name: 'Maintenance Release 4.17.0', @@ -304,7 +306,7 @@ describe('release-publish', () => { await runPublish({ github, core, env: publishEnv(), cwd: checkoutWith() }) - assert.strictEqual(requests.at(-1).body.make_latest, 'false') + assert.strictEqual(requests.find((request) => request.method === 'POST' && request.path === API.releases).body.make_latest, 'false') assert.match(logged(), /below the current latest release/) // The notes start at the release below the hotfix, not at the newer 4.18.0. assert.strictEqual(requests.find((request) => request.path === API.notes).body.previous_tag_name, '4.16.0') @@ -320,7 +322,7 @@ describe('release-publish', () => { await runPublish({ github, core, env: publishEnv(), cwd: checkoutWith() }) - assert.strictEqual(requests.at(-1).body.make_latest, 'true') + assert.strictEqual(requests.find((request) => request.method === 'POST' && request.path === API.releases).body.make_latest, 'true') }) it('uploads the files to a draft and publishes it last, so the release is never public without them', async (t) => { @@ -344,6 +346,8 @@ describe('release-publish', () => { `POST ${API.releases}`, 'POST uploads:/repos/stellarwp/plugin/releases/77/assets?name=plugin.zip', 'POST uploads:/repos/stellarwp/plugin/releases/77/assets?name=plugin.pot', + // The tag, read again before the draft is published: one made meanwhile would take over. + `GET ${API.tag}`, `PATCH ${API.release}`, ]) // Created as a draft, with no make_latest yet: that is decided when it is published. @@ -355,7 +359,7 @@ describe('release-publish', () => { draft: true, }) assert.strictEqual(String(requests[6].body), 'PK zip bytes') - assert.deepStrictEqual(requests[8].body, { draft: false, make_latest: 'true' }) + assert.deepStrictEqual(requests[9].body, { draft: false, make_latest: 'true' }) assert.deepStrictEqual(outputs(), { published: 'true', 'release-url': RELEASE_URL, sha: SHA }) assert.match(logged(), /Attached plugin\.zip.*Attached plugin\.pot/s) assert.match(summary(), /with `plugin\.zip`, `plugin\.pot`/) @@ -380,6 +384,62 @@ describe('release-publish', () => { assert.deepStrictEqual(outputs(), {}) }) + it('deletes the draft and refuses when the tag appeared while the files were uploading', async (t) => { + // The tag is absent at the first check and present before the draft is published: someone + // made it meanwhile. Publishing would attach the release to that commit. + const cwd = checkoutWith({ 'build/plugin.zip': 'zip' }) + const { github, requests } = await octokitFor([ + { + method: 'GET', + path: API.tag, + responses: [ + { status: 404, body: { message: 'Not Found' } }, + { body: { ref: 'refs/tags/4.17.0', object: { sha: 'abcabcabcabcabcabcabcabcabcabcabcabcabca', type: 'commit' } } }, + ], + }, + ...FRESH, + { method: 'POST', path: API.releases, status: 201, body: { id: 77, html_url: RELEASE_URL } }, + { method: 'POST', path: API.assets, status: 201, body: { id: 1 } }, + { method: 'DELETE', path: API.release, status: 204, body: {} }, + ]) + const { core, outputs } = await coreWithOutputs(t) + + await assert.rejects( + runPublish({ github, core, env: publishEnv({ INPUT_FILES: 'build/plugin.zip' }), cwd }), + /The tag 4\.17\.0 was created at abcabca.* while the files were uploading.*not 0123456.*The draft was deleted and no tag was made.*Nothing was published/s + ) + + assert.deepStrictEqual(requested(requests).slice(-2), [`GET ${API.tag}`, `DELETE ${API.release}`]) + assert.ok(!requested(requests).includes(`PATCH ${API.release}`), 'the draft is never published') + assert.deepStrictEqual(outputs(), {}) + }) + + it('fails naming the commit when a tag made between the check and the publish took the release', async (t) => { + // Without files there is no draft: the release is public by the time the tag is read again. + const { github, requests } = await octokitFor([ + { + method: 'GET', + path: API.tag, + responses: [ + { status: 404, body: { message: 'Not Found' } }, + { body: { ref: 'refs/tags/4.17.0', object: { sha: 'abcabcabcabcabcabcabcabcabcabcabcabcabca', type: 'commit' } } }, + ], + }, + ...FRESH, + { method: 'POST', path: API.releases, status: 201, body: { id: 77, html_url: RELEASE_URL } }, + ]) + const { core, outputs, summary } = await coreWithOutputs(t) + + await assert.rejects( + runPublish({ github, core, env: publishEnv(), cwd: checkoutWith() }), + /The tag 4\.17\.0 was created at abcabca.* between the check and the publish.*not to 0123456.*The release is public at https:\/\/github\.com\/stellarwp\/plugin\/releases\/tag\/4\.17\.0: delete it and the tag/s + ) + + assert.strictEqual(requested(requests).at(-1), `GET ${API.tag}`) + assert.deepStrictEqual(outputs(), {}) + assert.match(summary(), /published at `abcabca`, not at `0123456`/) + }) + it('deletes the draft when publishing it fails, as when a tag ruleset refuses the tag', async (t) => { const cwd = checkoutWith({ 'build/plugin.zip': 'zip' }) const { github, requests } = await octokitFor([ @@ -430,7 +490,7 @@ describe('release-publish', () => { await runPublish({ github, core, env: publishEnv(), cwd: checkoutWith() }) - assert.deepStrictEqual(requested(requests).slice(4), ['DELETE /repos/stellarwp/plugin/releases/70', `POST ${API.notes}`, `POST ${API.releases}`]) + assert.deepStrictEqual(requested(requests).slice(4), ['DELETE /repos/stellarwp/plugin/releases/70', `POST ${API.notes}`, `POST ${API.releases}`, `GET ${API.tag}`]) assert.match(logged(), /Deleted a draft of 4\.17\.0 left behind by an earlier run/) assert.strictEqual(outputs().published, 'true') }) From 335ff8c3384690e2be45ebd683bab8b3983ab3bb Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Wed, 4 Nov 2026 15:35:54 -0300 Subject: [PATCH 12/14] Say to pass the checkout's commit as target for a hand-merged release, not the branch The input's description recommended the branch for a release a person merged by hand and said the tag is made at the commit whatever the branch points to by then. A branch name is resolved to its tip when the action runs, so a push after the checkout would be tagged with assets built from another commit. The description now says what the README's publish-only snippet already does. --- .github/actions/release-publish/README.md | 2 +- .github/actions/release-publish/action.yml | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/actions/release-publish/README.md b/.github/actions/release-publish/README.md index 0b07050..ffcba3a 100644 --- a/.github/actions/release-publish/README.md +++ b/.github/actions/release-publish/README.md @@ -84,7 +84,7 @@ In this order, stopping at the first failure: | 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. | +| `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. | diff --git a/.github/actions/release-publish/action.yml b/.github/actions/release-publish/action.yml index 2a064d0..57980fb 100644 --- a/.github/actions/release-publish/action.yml +++ b/.github/actions/release-publish/action.yml @@ -19,9 +19,11 @@ inputs: 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. + 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.' From 590d64477e20cbff9bf0acec7e0d0d70b485fe42 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Wed, 4 Nov 2026 15:35:54 -0300 Subject: [PATCH 13/14] Pass the commit the run was started on to release-merge in the deploy template The approval of the live environment may come long after the run was started, and release-merge reads the branch's tip when its step runs. With head: github.sha a push to the release branch in between is refused rather than merged unreviewed. --- templates/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/templates/workflows/release.yml b/templates/workflows/release.yml index 93ca2d9..69864ac 100644 --- a/templates/workflows/release.yml +++ b/templates/workflows/release.yml @@ -69,6 +69,9 @@ jobs: id: merge with: branch: ${{ inputs.branch }} + # The commit the run was started on. The environment's approval may come long after that, + # and a push to the branch in between is refused rather than merged unreviewed. + head: ${{ github.sha }} version: ${{ inputs.version }} # base-branch: master # develop-branch: '' # for a repository without a develop branch From 2fb753bcbb11c53faaac5164f67ab4051f6a9d05 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Wed, 4 Nov 2026 17:06:59 -0300 Subject: [PATCH 14/14] Delete only the drafts this action made, known by the mark in their name The rerun deleted every draft whose tag name was the version, a draft a person made by hand to write the notes included. The action now names its draft [release-publish draft], publishes it under the title, and deletes only drafts that carry the mark. --- .github/actions/release-publish/README.md | 18 ++++++----- .../release-publish/release-publish.js | 32 +++++++++++++++---- .../release-publish/release-publish.test.js | 32 +++++++++++++++---- 3 files changed, 61 insertions(+), 21 deletions(-) diff --git a/.github/actions/release-publish/README.md b/.github/actions/release-publish/README.md index ffcba3a..2d45c6e 100644 --- a/.github/actions/release-publish/README.md +++ b/.github/actions/release-publish/README.md @@ -72,10 +72,10 @@ In this order, stopping at the first failure: 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**, uploads every file, then publishes it. 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, +6. With `files`, creates the release as a **draft** named `<title> [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. @@ -119,10 +119,12 @@ attached because the run died between the publish and a later step stays missing 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 of the version 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. When that delete fails, the -run warns and goes on. +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 diff --git a/.github/actions/release-publish/release-publish.js b/.github/actions/release-publish/release-publish.js index 439206f..44f763d 100644 --- a/.github/actions/release-publish/release-publish.js +++ b/.github/actions/release-publish/release-publish.js @@ -26,6 +26,13 @@ const { readRepository, isNotFound, readTag, tagExists } = require('../../lib/gi /** 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. * @@ -345,11 +352,14 @@ async function runPublish({ github, core, env, cwd = process.cwd() }) { core.info(`${version} is below the current latest release, so it is published without taking "Latest".`) } - // A draft of this version left by a run that was cancelled while uploading, or whose publish - // failed before the draft could be deleted. getReleaseByTag does not return drafts, so a rerun - // would otherwise leave it there and add a second one. + // A draft of this version left by a run of this action that was cancelled while uploading, or + // whose publish failed before the draft could be deleted. getReleaseByTag does not return + // drafts, so a rerun would otherwise leave it there and add a second one. Only drafts carrying + // the action's mark in their name: one a person made for the version is theirs. const releases = await listReleases({ github, owner, repo }) - for (const stale of releases.filter((release) => release.draft && release.tag_name === version)) { + const leftBehind = (release) => + release.draft && release.tag_name === version && String(release.name ?? '').endsWith(DRAFT_MARK) + for (const stale of releases.filter(leftBehind)) { try { await github.rest.repos.deleteRelease({ owner, repo, release_id: stale.id }) core.info(`Deleted a draft of ${version} left behind by an earlier run (${stale.html_url}).`) @@ -410,14 +420,14 @@ async function runPublish({ github, core, env, cwd = process.cwd() }) { } } else { // A draft makes no tag, so a failed upload can be undone by deleting it, and the release is - // never public without its files. + // never public without its files. Its name carries the mark until it is published. const draft = ( await github.rest.repos.createRelease({ owner, repo, tag_name: version, target_commitish: sha, - name: title, + name: `${title}${DRAFT_MARK}`, body, draft: true, }) @@ -455,7 +465,14 @@ async function runPublish({ github, core, env, cwd = process.cwd() }) { // draft is deleted here too, or a rerun would find none by the tag and leave it behind. try { release = ( - await github.rest.repos.updateRelease({ owner, repo, release_id: draft.id, draft: false, make_latest: latest }) + await github.rest.repos.updateRelease({ + owner, + repo, + release_id: draft.id, + draft: false, + name: title, + make_latest: latest, + }) ).data } catch (error) { const deleted = await deleteDraft({ github, owner, repo, draft }) @@ -481,4 +498,5 @@ module.exports = { readFiles, makeLatest, previousVersion, + DRAFT_MARK, } diff --git a/tests/actions/release-publish/release-publish.test.js b/tests/actions/release-publish/release-publish.test.js index 179f69f..383df60 100644 --- a/tests/actions/release-publish/release-publish.test.js +++ b/tests/actions/release-publish/release-publish.test.js @@ -16,6 +16,7 @@ const { readFiles, makeLatest, previousVersion, + DRAFT_MARK, } = require('../../../.github/actions/release-publish/release-publish.js') const { coreWithOutputs, octokitFor } = require('../../support/actions.js') @@ -350,16 +351,17 @@ describe('release-publish', () => { `GET ${API.tag}`, `PATCH ${API.release}`, ]) - // Created as a draft, with no make_latest yet: that is decided when it is published. + // Created as a draft, marked as the action's in its name, with no make_latest yet: that is + // decided when it is published, and the mark comes off then. assert.deepStrictEqual(requests[5].body, { tag_name: '4.17.0', target_commitish: SHA, - name: '4.17.0', + name: `4.17.0${DRAFT_MARK}`, body: '## What changed\n* Fix (#12)', draft: true, }) assert.strictEqual(String(requests[6].body), 'PK zip bytes') - assert.deepStrictEqual(requests[9].body, { draft: false, make_latest: 'true' }) + assert.deepStrictEqual(requests[9].body, { draft: false, name: '4.17.0', make_latest: 'true' }) assert.deepStrictEqual(outputs(), { published: 'true', 'release-url': RELEASE_URL, sha: SHA }) assert.match(logged(), /Attached plugin\.zip.*Attached plugin\.pot/s) assert.match(summary(), /with `plugin\.zip`, `plugin\.pot`/) @@ -476,9 +478,27 @@ describe('release-publish', () => { ) }) + it('leaves alone a draft of the version a person made by hand', async (t) => { + // Same tag name, no mark: someone is writing the notes for 4.17.0 in the GitHub UI. + const theirs = { id: 69, tag_name: '4.17.0', name: '4.17.0', draft: true, html_url: 'https://github.com/stellarwp/plugin/releases/untagged-theirs' } + const { github, requests } = await octokitFor([ + { method: 'POST', path: API.notes, body: { body: 'notes' } }, + { method: 'GET', path: API.latest, body: { tag_name: '4.16.0' } }, + { method: 'GET', path: API.list, body: [theirs, ...RELEASES] }, + { method: 'POST', path: API.releases, status: 201, body: { id: 77, html_url: RELEASE_URL } }, + ]) + const { core, outputs } = await coreWithOutputs(t) + + await runPublish({ github, core, env: publishEnv(), cwd: checkoutWith() }) + + assert.ok(!requested(requests).some((line) => line.startsWith('DELETE')), 'nothing is deleted') + assert.strictEqual(outputs().published, 'true') + }) + it('deletes a draft of the version left behind by an earlier run before publishing', async (t) => { - // A run cancelled while uploading leaves its draft; getReleaseByTag does not return drafts. - const stale = { id: 70, tag_name: '4.17.0', draft: true, html_url: 'https://github.com/stellarwp/plugin/releases/untagged-abc' } + // A run cancelled while uploading leaves its draft, named with the mark; getReleaseByTag does + // not return drafts. + const stale = { id: 70, tag_name: '4.17.0', name: `4.17.0${DRAFT_MARK}`, draft: true, html_url: 'https://github.com/stellarwp/plugin/releases/untagged-abc' } const { github, requests } = await octokitFor([ { method: 'POST', path: API.notes, body: { body: 'notes' } }, { method: 'GET', path: API.latest, body: { tag_name: '4.16.0' } }, @@ -496,7 +516,7 @@ describe('release-publish', () => { }) it('warns and goes on when a draft left behind cannot be deleted', async (t) => { - const stale = { id: 70, tag_name: '4.17.0', draft: true, html_url: 'https://github.com/stellarwp/plugin/releases/untagged-abc' } + const stale = { id: 70, tag_name: '4.17.0', name: `4.17.0${DRAFT_MARK}`, draft: true, html_url: 'https://github.com/stellarwp/plugin/releases/untagged-abc' } const { github } = await octokitFor([ { method: 'POST', path: API.notes, body: { body: 'notes' } }, { method: 'GET', path: API.latest, body: { tag_name: '4.16.0' } },