From 41185b1e45847366cb03d5e0adea51f24b7ea247 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 16:33:37 -0300 Subject: [PATCH 1/8] 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 0b9a211..273b278 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 @@ -138,7 +142,7 @@ two different branches (when there is a develop), and the release branch cannot |---|---| | `version` | The version released. | | `branch` | The release branch merged. | -| `base-sha` | The commit on the base branch that holds the release: the merge commit, or the base's tip on a retry that found the merge done. The commit to check out, build from and publish the release at. | +| `base-sha` | The commit on the base branch that holds the release: the merge commit, or the base's tip on a retry that found the merge done. The commit to check out and build from; pass it to release-publish as `target`. | | `conflict` | The branch a merge conflicted on, or empty. A conflict fails the step, so read it with `continue-on-error` or in a later `if: failure()` step. | ## One deploy at a time @@ -177,8 +181,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 6faa341..6713e71 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, @@ -23,7 +24,7 @@ inputs: paths.versions is read, and a different version in any of them is refused. A pre-release such as 4.17.0-beta.1 and an empty value are refused before anything is merged, and so is a version whose tag already exists, unless that tag's release was published from this branch, which makes - the run a retry. Publishing the release is the caller's, after this action. + the run a retry. The release itself is published by release-publish, after this action. required: true base-branch: description: 'The branch the release is merged into: `main`, or `master` where that is the name.' @@ -55,8 +56,8 @@ outputs: base-sha: description: >- The commit on the base branch that holds the release: the merge commit, or the base's tip on a - retry that found the merge already done. The commit to check out, build from and publish the - release at. + retry that found the merge already done. The commit to check out and build from; pass it to + release-publish as `target`. value: ${{ steps.merge.outputs.base-sha }} conflict: description: >- 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 551f89e..a4cc278 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,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 d7f272af254b6944fa9862eb1e3a0cc3fb1f6fa9 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 17:25:12 -0300 Subject: [PATCH 2/8] 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 273b278..7930f35 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 a4cc278..7a93823 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,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 e1d1b34bff0924c2bc69271e29b7f9a143f46d45 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 17:46:19 -0300 Subject: [PATCH 3/8] 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 4e043302020eb30a3f26666d23b01d222269a3bd Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 17:53:53 -0300 Subject: [PATCH 4/8] 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 f4e32c64bc978319d8421da660d7596dde652a85 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 18:37:47 -0300 Subject: [PATCH 5/8] 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 db2851575c3a93f99ff1836a0336db3b87a065fb Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 18:49:01 -0300 Subject: [PATCH 6/8] 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 7930f35..fa5bb54 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 7a93823..1491384 100644 --- a/README.md +++ b/README.md @@ -160,8 +160,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 21aa3c7a19356add7c94999c55685525db9cf58a Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 18:55:09 -0300 Subject: [PATCH 7/8] 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 356cf3ea58216dbe2c3529f53657172522419664 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Tue, 3 Nov 2026 15:26:49 -0300 Subject: [PATCH 8/8] 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