From ef939b9751aacae656b6b0c8468acbbebdca7931 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Fri, 30 Oct 2026 13:44:42 -0300 Subject: [PATCH 01/12] Share the version pattern and the real-exec test helper release-prep's three-or-four-parts pattern moves to .github/lib/versions.js, where the other version rules live, so the release-merge action applies the same rule from the same place. The wrapper around the real @actions/exec moves from the release-prep test into tests/support, for the tests that run git for real. --- .github/actions/release-prep/release-prep.js | 10 ++---- .github/lib/versions.js | 10 +++++- .../actions/release-prep/release-prep.test.js | 33 +------------------ tests/support/actions.js | 33 +++++++++++++++++++ 4 files changed, 45 insertions(+), 41 deletions(-) diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index 1729801..0b2c134 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -16,14 +16,8 @@ const os = require('node:os') const path = require('node:path') const { execFileSync } = require('node:child_process') -const { compareVersions } = require('../../lib/versions.js') - -/** - * Three or four numeric parts, the shape the stellarwp changelog versioning accepts. That strategy - * also accepts an empty part (it reads it as 0, so it would take 2.1..21); this pattern rejects - * that. A version of this shape is also always a valid tag name. - */ -const STELLARWP_VERSION_PATTERN = /^\d+\.\d+\.\d+(\.\d+)?$/ +// The three-or-four-parts pattern lives with the other version rules, shared with release-merge. +const { compareVersions, STELLARWP_VERSION_PATTERN } = require('../../lib/versions.js') /** * A semver version as semver.org defines it: MAJOR.MINOR.PATCH, an optional pre-release and diff --git a/.github/lib/versions.js b/.github/lib/versions.js index d9f840e..bc33847 100644 --- a/.github/lib/versions.js +++ b/.github/lib/versions.js @@ -9,6 +9,14 @@ * and nothing for a caller to install. */ +/** + * Three or four numeric parts, the shape the stellarwp changelog versioning accepts and the one the + * release-merge action publishes. That strategy also accepts an empty part (it reads it as 0, so it + * would take 2.1..21); this pattern rejects that. A version of this shape is also always a valid tag + * name. + */ +const STELLARWP_VERSION_PATTERN = /^\d+\.\d+\.\d+(\.\d+)?$/ + /** * Splits a version into its numbers, for comparing one part against another. * @@ -54,4 +62,4 @@ function compareVersions(a, b) { return 0 } -module.exports = { toParts, compareVersions } +module.exports = { toParts, compareVersions, STELLARWP_VERSION_PATTERN } diff --git a/tests/actions/release-prep/release-prep.test.js b/tests/actions/release-prep/release-prep.test.js index 378bfdc..319da0e 100644 --- a/tests/actions/release-prep/release-prep.test.js +++ b/tests/actions/release-prep/release-prep.test.js @@ -25,7 +25,7 @@ const { compareVersions, readCurrentVersion, } = require('../../../.github/actions/release-prep/release-prep.js') -const { coreWithOutputs, recordingExec, octokitFor } = require('../../support/actions.js') +const { coreWithOutputs, recordingExec, recordedRealExec, octokitFor } = require('../../support/actions.js') /** A .puprc that bumps the plugin header and replaces TBDs under src/. */ const PUPRC = { @@ -191,37 +191,6 @@ function requested(requests) { return requests.map((request) => `${request.method} ${request.path}`) } -/** - * The real @actions/exec, wrapped to record each call and what it printed. - * - * What a command prints is sent to a buffer of its own rather than to stdout, where the test runner - * reports results: that covers the `[command]` line @actions/exec echoes, so a test can assert what - * a run would have shown in the log. - * - * @returns {Promise<{exec: object, calls: object[], printed: Function}>} The wrapper, the calls it - * recorded, and a reader for everything the commands printed. - */ -async function recordedRealExec() { - const { Writable } = require('node:stream') - const real = await import('@actions/exec') - const calls = [] - const chunks = [] - const sink = new Writable({ - write(chunk, encoding, callback) { - chunks.push(String(chunk)) - callback() - }, - }) - - const getExecOutput = (command, args = [], options = {}) => { - calls.push({ command, args, options }) - - return real.getExecOutput(command, args, { ...options, outStream: sink, errStream: sink }) - } - - return { exec: { getExecOutput }, calls, printed: () => chunks.join('') } -} - /** * The environment the manifest hands the commit step. * diff --git a/tests/support/actions.js b/tests/support/actions.js index 35a02a4..0c9f97c 100644 --- a/tests/support/actions.js +++ b/tests/support/actions.js @@ -308,6 +308,38 @@ function recordingExec(rules = []) { return { exec, calls } } +/** + * The real @actions/exec, wrapped to record each call and what it printed. + * + * For an action whose logic is in the commands themselves, such as git merges, a stand-in would only + * test the test. What a command prints is sent to a buffer of its own rather than to stdout, where + * the test runner reports results: that covers the `[command]` line @actions/exec echoes, so a test + * can assert what a run would have shown in the log. + * + * @returns {Promise<{exec: object, calls: object[], printed: Function}>} The wrapper, the calls it + * recorded, and a reader for everything the commands printed. + */ +async function recordedRealExec() { + const { Writable } = require('node:stream') + const real = await import('@actions/exec') + const calls = [] + const chunks = [] + const sink = new Writable({ + write(chunk, encoding, callback) { + chunks.push(String(chunk)) + callback() + }, + }) + + const getExecOutput = (command, args = [], options = {}) => { + calls.push({ command, args, options }) + + return real.getExecOutput(command, args, { ...options, outStream: sink, errStream: sink }) + } + + return { exec: { getExecOutput }, calls, printed: () => chunks.join('') } +} + /** * Both arguments actions/github-script passes to a script, ready for one test. * @@ -331,6 +363,7 @@ module.exports = { coreWithOutputs, recordingFetch, recordingExec, + recordedRealExec, readOutputs, isLogWrite, } From 84d596197df05943eb873e18bd5ab2634032639c Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Fri, 30 Oct 2026 13:44:42 -0300 Subject: [PATCH 02/12] Add the release-merge action Merges the release branch into the base branch and the base back into develop, then publishes the GitHub release for the version. Every step is safe to rerun: a branch that already holds the other merges as a no-op and an existing release is skipped. A conflict abandons the merge, prints a MERGE_CONFLICT line the release dashboard reads, and fails with the branch named. The token reaches git through the environment of the fetch and the pushes only, never the checkout's config, so the caller can keep persist-credentials off and run third-party actions after it. The tests run the merges with real git against a bare repository on disk. --- .github/actions/release-merge/action.yml | 106 +++ .../actions/release-merge/release-merge.js | 531 +++++++++++++++ .../release-merge/release-merge.test.js | 617 ++++++++++++++++++ 3 files changed, 1254 insertions(+) create mode 100644 .github/actions/release-merge/action.yml create mode 100644 .github/actions/release-merge/release-merge.js create mode 100644 tests/actions/release-merge/release-merge.test.js diff --git a/.github/actions/release-merge/action.yml b/.github/actions/release-merge/action.yml new file mode 100644 index 0000000..c82c4f1 --- /dev/null +++ b/.github/actions/release-merge/action.yml @@ -0,0 +1,106 @@ +# Merges a release: the release branch into the base branch, the base branch back into develop, then +# publishes the GitHub release for the version. The part of a deploy that is the same for every +# plugin; what happens after the merge (building, wordpress.org, announcements) is the caller's. +# +# The checks, the merges and the publish live in release-merge.js, run by actions/github-script, so +# there is no dependency to install and nothing to build. The merges are git in the caller's checkout, +# the tag and release reads and the publish go through the GitHub API. +name: Release merge +description: Merges the release branch into the base and the base back into develop, then publishes the GitHub release + +inputs: + branch: + description: >- + The release branch to merge, e.g. `release/M26.jigglypuff`. A branch name, not any git ref. It is + fetched from `origin`; the caller's checkout can be on any branch and at any depth. + required: true + version: + description: >- + The version being released: three or four numeric parts, such as 4.17.0 or 4.17.0.1, the same + rule the release dashboard and release-prep apply. It becomes the tag and the title of the + GitHub release. 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 without a published release. + required: true + base-branch: + description: 'The branch the release is merged into: `main`, or `master` where that is the name.' + required: false + default: 'main' + develop-branch: + description: 'The branch the base is merged back into after the release.' + required: false + default: 'develop' + publish: + description: >- + Whether to publish the GitHub release once both merges are pushed: `true` or `false`. Set it to + false to use the two merges on their own. + required: false + default: 'true' + token: + description: >- + Token the pushes and the release are made with. It needs `contents: write` on the repository, so + the job needs that permission too. It is handed to git through the environment of the fetch and + the pushes only, never written to the checkout's config, so the checkout can use + `persist-credentials: false`. A release published with the default token triggers no `release` + workflow, which is the point: the run that merged the release is the one that deploys it. + required: false + default: ${{ github.token }} + +outputs: + version: + description: 'The version released.' + value: ${{ steps.merge.outputs.version }} + branch: + description: 'The release branch merged.' + value: ${{ steps.merge.outputs.branch }} + conflict: + description: >- + Which branch a merge conflicted on, `main` or `develop` for the defaults, or empty when both + merges went through. A conflict fails the step, so a caller reads this with `continue-on-error` + or in a later `if: failure()` step. + value: ${{ steps.merge.outputs.conflict }} + published: + description: '`true` when the release was created, `skipped` when it already existed, `false` when publish was false.' + value: ${{ steps.publish.outputs.published }} + release-url: + description: 'The GitHub release page, empty when publish was false.' + value: ${{ steps.publish.outputs.release-url }} + +runs: + using: 'composite' + steps: + - name: Merge the release into the base and the base back into develop + id: merge + uses: actions/github-script@v9 + env: + # github-script's require() resolves against the workspace of the repository being built, + # not this action's directory, so the path has to be passed in. github.action_path is the + # documented expression for it. + ACTION_PATH: ${{ github.action_path }} + # Read from process.env rather than interpolated into the script, so an input is data and + # never part of the program. + INPUT_BRANCH: ${{ inputs.branch }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_BASE_BRANCH: ${{ inputs.base-branch }} + INPUT_DEVELOP_BRANCH: ${{ inputs.develop-branch }} + INPUT_TOKEN: ${{ inputs.token }} + with: + # The tag and release reads go through github-script's own client, built from this token. + github-token: ${{ inputs.token }} + script: | + const { runMerge } = require(`${process.env.ACTION_PATH}/release-merge.js`) + await runMerge({ github, core, exec, env: process.env }) + + - name: Publish the GitHub release + id: publish + uses: actions/github-script@v9 + env: + ACTION_PATH: ${{ github.action_path }} + # The values the merge step validated, not the raw inputs. + INPUT_VERSION: ${{ steps.merge.outputs.version }} + INPUT_BASE_BRANCH: ${{ steps.merge.outputs.base-branch }} + INPUT_PUBLISH: ${{ inputs.publish }} + with: + github-token: ${{ inputs.token }} + script: | + const { runPublish } = require(`${process.env.ACTION_PATH}/release-merge.js`) + await runPublish({ github, core, env: process.env }) diff --git a/.github/actions/release-merge/release-merge.js b/.github/actions/release-merge/release-merge.js new file mode 100644 index 0000000..66033ce --- /dev/null +++ b/.github/actions/release-merge/release-merge.js @@ -0,0 +1,531 @@ +'use strict' + +/** + * Merges a release: the release branch into the base branch, the base branch back into develop, then + * publishes the GitHub release for the version. + * + * The action runs in two github-script steps, so there are two entry points. runMerge checks the + * inputs and the version against the repository's tags, then does the two merges with git in the + * caller's checkout and pushes them; runPublish creates the GitHub release through the Octokit + * github-script supplies. The functions they are built from are pure, apart from the git commands run + * through `exec` and the API calls, so a test can run them against a local remote and assert the + * requests. + * + * Every step is safe to run again after a late failure: a branch that already holds the release + * merges as a no-op, and a release that already exists is skipped. + */ + +const { STELLARWP_VERSION_PATTERN } = require('../../lib/versions.js') + +/** The identity the merge commits are made with: the one GitHub gives the default token. */ +const BOT_IDENTITY = { + name: 'github-actions[bot]', + email: '41898282+github-actions[bot]@users.noreply.github.com', +} + +/** + * The lines git prints when a merge stops on a conflict: one `CONFLICT (…)` per file, then the + * closing line. The release dashboard reads the same two strings from the job log as its fallback. + */ +const CONFLICT_OUTPUT = /CONFLICT \(|Automatic merge failed/ + +/** + * Reads the version to release, refusing anything but three or four numeric parts. + * + * A pre-release is refused on purpose: the version becomes the tag and the title of a published + * release, and a beta or RC is not released this way. The same rule validates the version in the + * release dashboard and in release-prep, so a version that gets this far has already passed it twice. + * + * @param {string} version The `version` input. + * + * @throws {Error} When it is empty or not of that shape. + * + * @returns {string} The version, without surrounding whitespace. + */ +function validateVersion(version) { + const trimmed = String(version ?? '').trim() + + if (trimmed === '') { + throw new Error( + 'No version was given. The run was dispatched without one, which is what a release with no ' + + "version set sends: set the release's version and run this step again. Nothing was merged." + ) + } + + if (!STELLARWP_VERSION_PATTERN.test(trimmed)) { + throw new Error( + `'${trimmed}' is not a version this action can release. It takes three or four numeric parts, ` + + 'such as 4.17.0 or 4.17.0.1: no pre-release suffix and no leading v, because the version ' + + 'becomes the tag and the title of the published release. Nothing was merged.' + ) + } + + return trimmed +} + +/** + * Checks the shape of a branch input. Whether git accepts the name is checked by git, in runMerge. + * + * @param {string} branch The input value. + * @param {string} input Which input it is, for the message: `branch`, `base-branch` or + * `develop-branch`. + * + * @throws {Error} When it is empty or a full ref. + * + * @returns {string} The branch name, without surrounding whitespace. + */ +function validateBranch(branch, input) { + const trimmed = String(branch ?? '').trim() + + if (trimmed === '') { + throw new Error(`No ${input} was given: pass a branch name, such as release/M26.test. Nothing was merged.`) + } + + if (trimmed.startsWith('refs/')) { + throw new Error( + `Refusing ${input} '${trimmed}': pass the branch name, e.g. '${trimmed.replace(/^refs\/heads\//, '')}'. ` + + 'Nothing was merged.' + ) + } + + return trimmed +} + +/** + * Reads the `publish` input. + * + * @param {string} input The input: empty, `true` or `false`. + * + * @throws {Error} When it is anything else. + * + * @returns {boolean} Whether to publish the GitHub release; true for an empty input. + */ +function readPublish(input) { + const trimmed = String(input ?? '').trim().toLowerCase() + + if (trimmed === '' || trimmed === 'true') { + return true + } + + if (trimmed === 'false') { + return false + } + + throw new Error(`publish '${trimmed}' is not a value this action takes: pass true or false.`) +} + +/** + * The header git sends to authenticate as the token, the same one actions/checkout stores when it + * persists credentials. + * + * @param {string} token The token. + * + * @returns {string} The `AUTHORIZATION: basic …` header. + */ +function authorizationHeader(token) { + return `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}` +} + +/** + * The environment that hands git the token for one command, scoped to the server the run is on. + * + * Git reads `GIT_CONFIG_COUNT` and the numbered key/value pairs as configuration for that process + * only, so the token reaches the fetch and the pushes and nothing else: it is never written to + * .git/config, where a later step of the same job would find it, and it is never on a command line, + * which @actions/exec echoes into the log. + * + * @param {string} token The token. + * @param {string} serverUrl The GitHub server, `https://github.com` or a GitHub Enterprise URL. + * + * @returns {object} The three variables. + */ +function gitAuthEnv(token, serverUrl) { + const server = String(serverUrl || 'https://github.com').replace(/\/+$/, '') + + return { + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: `http.${server}/.extraheader`, + GIT_CONFIG_VALUE_0: authorizationHeader(token), + } +} + +/** + * The message of a merge commit. + * + * @param {string} from The branch merged. + * @param {string} into The branch it was merged into. + * + * @returns {string} The message. + */ +function mergeMessage(from, into) { + return `Merge ${from} into ${into}` +} + +/** + * Whether a failed `git merge` stopped on a conflict, as opposed to failing for any other reason. + * + * @param {{exitCode: number, stdout: string, stderr: string}} result What the merge returned. + * + * @returns {boolean} True for a conflict. + */ +function isConflict(result) { + return result.exitCode !== 0 && CONFLICT_OUTPUT.test(`${result.stdout}\n${result.stderr}`) +} + +/** + * Whether an Octokit error is the API saying a thing does not exist. + * + * @param {Error} error The error. + * + * @returns {boolean} True for a 404. + */ +function isNotFound(error) { + return error?.status === 404 +} + +/** + * Checks the version against the repository's tags before anything is merged. + * + * A version that was never tagged is the common case. One whose tag exists with a published release + * is a retry after a late failure: the merges will find nothing to do and the publish step will skip + * it. One whose tag exists without a release would make the publish step reuse whatever commit the + * tag points at, so it is refused here, with the base and develop untouched. + * + * @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.version The version, validated. + * + * @throws {Error} When the tag exists without a release, or a read fails for any reason but 404. + * + * @returns {Promise<{releaseUrl: string|null}>} The published release's URL, or null when the + * version was never tagged. + */ +async function runPreflight({ github, owner, repo, version }) { + try { + await github.rest.git.getRef({ owner, repo, ref: `tags/${version}` }) + } catch (error) { + if (isNotFound(error)) { + return { releaseUrl: null } + } + + throw new Error(`Reading tag ${version} failed (HTTP ${error.status}): ${error.message}. Nothing was merged.`) + } + + try { + const { data: release } = await github.rest.repos.getReleaseByTag({ owner, repo, tag: version }) + + return { releaseUrl: release.html_url } + } catch (error) { + if (isNotFound(error)) { + 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 was merged.' + ) + } + + throw new Error( + `Reading the release for ${version} failed (HTTP ${error.status}): ${error.message}. Nothing was merged.` + ) + } +} + +/** + * Reads `GITHUB_REPOSITORY` as owner and name. + * + * @param {object} env The environment. + * @param {string} what What the action was about to do, to close the message. + * + * @throws {Error} When it is not set. + * + * @returns {{owner: string, repo: string}} The repository. + */ +function readRepository(env, what) { + const [owner, repo] = String(env.GITHUB_REPOSITORY || '').split('/') + + if (!owner || !repo) { + throw new Error(`GITHUB_REPOSITORY is not set, so the action does not know which repository to work on. ${what}`) + } + + return { owner, repo } +} + +/** + * Runs a git command in the checkout and fails with its output when it exits non-zero. + * + * @param {object} exec The @actions/exec toolkit. + * @param {string[]} args The git arguments. + * @param {object} options Options for @actions/exec, `cwd` among them. + * @param {string} what What the command does, to open the error message. + * + * @throws {Error} When git exits non-zero. + * + * @returns {Promise} What git printed to stdout. + */ +async function gitOrFail(exec, args, options, what) { + const result = await exec.getExecOutput('git', args, { ...options, ignoreReturnCode: true }) + + if (result.exitCode !== 0) { + const output = [result.stdout, result.stderr].map((text) => text.trim()).filter(Boolean) + + throw new Error(`${what} failed (exit code ${result.exitCode}):\n${output.join('\n')}`) + } + + return result.stdout +} + +/** + * Merges one branch into the branch the checkout is on, as a merge commit, and pushes it. + * + * A branch that already holds the other merges as "Already up to date" and pushes nothing, which is + * what a retry after a late failure finds. A conflict abandons the merge, prints the MERGE_CONFLICT + * marker on a line of its own, where the release dashboard looks for it, and fails. + * + * @param {object} options + * @param {object} options.core The @actions/core toolkit. + * @param {object} options.exec The @actions/exec toolkit. + * @param {string} options.cwd The checkout. + * @param {object} options.authEnv The environment that hands git the token, for the push. + * @param {string} options.from The ref to merge, e.g. `origin/release/M26.test`. + * @param {string} options.into The branch the checkout is on, e.g. `main`. + * @param {string} options.message The merge commit message. + * @param {string} options.label How to name `from` in the log, e.g. `release/M26.test`. + * + * @throws {Error} When the merge conflicts or fails, or the push fails. + * + * @returns {Promise} True when a merge commit was made, false when there was nothing to merge. + */ +async function mergeAndPush({ core, exec, cwd, authEnv, from, into, message, label }) { + const identity = ['-c', `user.name=${BOT_IDENTITY.name}`, '-c', `user.email=${BOT_IDENTITY.email}`] + const result = await exec.getExecOutput('git', [...identity, 'merge', '--no-ff', '-m', message, from], { + cwd, + ignoreReturnCode: true, + }) + + if (isConflict(result)) { + // The abort leaves the checkout clean, so a later step can still use it. core.info prints a + // plain line: the marker has to open an output line to count, see the README. + await exec.getExecOutput('git', ['merge', '--abort'], { cwd, ignoreReturnCode: true }) + core.setOutput('conflict', into) + core.info( + `MERGE_CONFLICT: merging ${label} into ${into} conflicted. Resolve it on ${into} by hand and ` + + `push, then run this step again. Nothing was pushed to ${into}.` + ) + throw new Error( + `Merging ${label} into ${into} stopped on a conflict. Resolve it on ${into} by hand and push, ` + + `then run this step again: the merges already done are kept. Nothing was pushed to ${into}.` + ) + } + + if (result.exitCode !== 0) { + const output = [result.stdout, result.stderr].map((text) => text.trim()).filter(Boolean) + + throw new Error(`Merging ${label} into ${into} failed (exit code ${result.exitCode}):\n${output.join('\n')}`) + } + + const merged = !/Already up to date/.test(result.stdout) + if (!merged) { + core.info(`${into} already has ${label}: nothing to merge.`) + } + + await gitOrFail(exec, ['push', 'origin', `HEAD:refs/heads/${into}`], { cwd, env: authEnv }, `Pushing ${into}`) + + return merged +} + +/** + * Checks the inputs and the version, then merges the release branch into the base branch and the + * base branch back into develop, pushing each. + * + * The base branch, not the release branch, is merged into develop: develop then descends from the + * base, and consecutive releases bring one version bump each instead of divergent copies of the same + * lines. Both merges are `--no-ff`, so each release is one merge commit in the history. + * + * The checkout is the caller's, on any branch and at any depth: the three branches are fetched, with + * the full history when the checkout is shallow, and the checkout is left on the base branch at the + * merged commit, for the steps that build the release from it. + * + * @param {object} options + * @param {object} options.github The Octokit github-script supplies, for the tag and release reads. + * @param {object} options.core The @actions/core toolkit, for the log, the outputs and the summary. + * @param {object} options.exec The @actions/exec toolkit. + * @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, the tag exists without a release, a merge conflicts or + * fails, or a fetch or push fails. + * + * @returns {Promise} Resolves once both branches are pushed and the outputs are set. + */ +async function runMerge({ github, core, exec, env, cwd = process.cwd() }) { + const branch = validateBranch(env.INPUT_BRANCH, 'branch') + const version = validateVersion(env.INPUT_VERSION) + const base = validateBranch(env.INPUT_BASE_BRANCH, 'base-branch') + const develop = validateBranch(env.INPUT_DEVELOP_BRANCH, 'develop-branch') + const { owner, repo } = readRepository(env, 'Nothing was merged.') + + if (base === develop) { + throw new Error(`base-branch and develop-branch are both '${base}'; they have to be two branches. Nothing was merged.`) + } + if (branch === base || branch === develop) { + throw new Error(`branch '${branch}' is also the ${branch === base ? 'base' : 'develop'} branch. Nothing was merged.`) + } + + for (const name of [branch, base, develop]) { + const check = await exec.getExecOutput('git', ['check-ref-format', `refs/heads/${name}`], { + cwd, + ignoreReturnCode: true, + silent: true, + }) + if (check.exitCode !== 0) { + throw new Error(`'${name}' is not a valid branch name. Nothing was merged.`) + } + } + + const { releaseUrl } = await runPreflight({ github, owner, repo, version }) + if (releaseUrl) { + core.info(`${version} is already published (${releaseUrl}); the publish step will skip it.`) + } + + const token = String(env.INPUT_TOKEN || '') + const authEnv = { ...env, ...gitAuthEnv(token, env.GITHUB_SERVER_URL) } + if (token) { + core.setSecret(authorizationHeader(token)) + } + + // A shallow checkout, the actions/checkout default, has no history for the merges to find a base + // in; `--unshallow` fetches it along with the branches. + const shallow = (await exec.getExecOutput('git', ['rev-parse', '--is-shallow-repository'], { cwd, silent: true })).stdout.trim() + const refspecs = [branch, base, develop].map((name) => `+refs/heads/${name}:refs/remotes/origin/${name}`) + await gitOrFail( + exec, + ['fetch', ...(shallow === 'true' ? ['--unshallow'] : []), 'origin', ...refspecs], + { cwd, env: authEnv }, + 'Fetching the branches' + ) + + await gitOrFail(exec, ['checkout', '--quiet', '-B', base, `origin/${base}`], { cwd }, `Checking out ${base}`) + const mergedBase = await mergeAndPush({ + core, + exec, + cwd, + authEnv, + from: `origin/${branch}`, + into: base, + message: mergeMessage(branch, base), + label: branch, + }) + + await gitOrFail(exec, ['checkout', '--quiet', '-B', develop, `origin/${develop}`], { cwd }, `Checking out ${develop}`) + const mergedDevelop = await mergeAndPush({ + core, + exec, + cwd, + authEnv, + from: base, + into: develop, + message: `Merge ${base} back into ${develop} (${branch})`, + label: base, + }) + + await gitOrFail(exec, ['checkout', '--quiet', base], { cwd }, `Checking out ${base}`) + + core.setOutput('branch', branch) + core.setOutput('version', version) + core.setOutput('base-branch', base) + core.setOutput('develop-branch', develop) + core.setOutput('conflict', '') + + const did = (merged, from, into) => (merged ? `merged \`${from}\` into \`${into}\`` : `\`${into}\` already had \`${from}\``) + await core.summary + .addRaw(`Release ${version}: ${did(mergedBase, branch, base)}, ${did(mergedDevelop, base, develop)}.`, true) + .write() +} + +/** + * Publishes the GitHub release for the version, unless `publish` is false or it already exists. + * + * The release is checked again here rather than read from the merge step: a retry, or another run + * publishing in between, finds the truth either way. Its notes are the ones GitHub generates from + * the pull requests since the previous release. + * + * @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 the merge step's outputs in. + * + * @throws {Error} When `publish` is not true or false, or an API call fails. + * + * @returns {Promise} Resolves once the release exists, or was found unnecessary. + */ +async function runPublish({ github, core, env }) { + const publish = readPublish(env.INPUT_PUBLISH) + const version = validateVersion(env.INPUT_VERSION) + const base = validateBranch(env.INPUT_BASE_BRANCH, 'base-branch') + + if (!publish) { + core.info(`publish is false: the GitHub release for ${version} was not created.`) + core.setOutput('published', 'false') + core.setOutput('release-url', '') + + return + } + + const { owner, repo } = readRepository(env, 'Nothing was published.') + + 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 was published.` + ) + } + } + + 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) + await core.summary.addRaw(`Release ${version} was already published: ${existing.html_url}`, true).write() + + return + } + + const { data: notes } = await github.rest.repos.generateReleaseNotes({ + owner, + repo, + tag_name: version, + target_commitish: base, + }) + const { data: release } = await github.rest.repos.createRelease({ + owner, + repo, + tag_name: version, + target_commitish: base, + name: version, + body: notes.body, + }) + + core.info(`Published ${version}: ${release.html_url}`) + core.setOutput('published', 'true') + core.setOutput('release-url', release.html_url) + await core.summary.addRaw(`Published release ${version} on \`${base}\`: ${release.html_url}`, true).write() +} + +module.exports = { + runMerge, + runPublish, + runPreflight, + validateVersion, + validateBranch, + readPublish, + authorizationHeader, + gitAuthEnv, + mergeMessage, + isConflict, + BOT_IDENTITY, +} diff --git a/tests/actions/release-merge/release-merge.test.js b/tests/actions/release-merge/release-merge.test.js new file mode 100644 index 0000000..8f1beda --- /dev/null +++ b/tests/actions/release-merge/release-merge.test.js @@ -0,0 +1,617 @@ +'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 { execFileSync } = require('node:child_process') + +const { + runMerge, + runPublish, + runPreflight, + validateVersion, + validateBranch, + readPublish, + authorizationHeader, + gitAuthEnv, + mergeMessage, + isConflict, + BOT_IDENTITY, +} = require('../../../.github/actions/release-merge/release-merge.js') +const { coreWithOutputs, recordedRealExec, recordingExec, octokitFor } = require('../../support/actions.js') + +/** + * Runs git in a directory and returns its trimmed output. + * + * @param {string} dir The directory to run in. + * @param {string[]} args The git arguments. + * + * @returns {string} What git printed. + */ +function git(dir, ...args) { + return execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() +} + +/** + * Commits a file in a working copy. + * + * @param {string} dir The working copy. + * @param {string} name The file, relative to it. + * @param {string} contents What to write. + * @param {string} message The commit message. + */ +function commitFile(dir, name, contents, message) { + fs.writeFileSync(path.join(dir, name), contents) + git(dir, 'add', '-A') + git(dir, 'commit', '--quiet', '-m', message) +} + +/** + * A remote repository with the three branches the action merges, as a plugin repository has them + * after release prep: `main` at 4.16.0, `develop` ahead of it by one commit, and `release/M26.test` + * holding the bump to 4.17.0. + * + * The remote is a bare repository on disk, so the action's fetches and pushes are real git against a + * real remote, with no network. + * + * @param {object} options + * @param {boolean} options.baseConflicts Also move `main` on the line the release changes, so + * merging the release into it conflicts. + * @param {boolean} options.developConflicts Also move `develop` on that line, so merging `main` back + * into it conflicts. + * + * @returns {string} The bare repository. + */ +function remoteWith({ baseConflicts = false, developConflicts = false } = {}) { + const bare = fs.mkdtempSync(path.join(os.tmpdir(), 'release-merge-remote-')) + const seed = fs.mkdtempSync(path.join(os.tmpdir(), 'release-merge-seed-')) + + git(bare, 'init', '--quiet', '--bare', '-b', 'main') + + git(seed, 'init', '--quiet', '-b', 'main') + git(seed, 'config', 'user.name', 'Test') + git(seed, 'config', 'user.email', 'test@example.com') + commitFile(seed, 'plugin.php', 'Version: 4.16.0\n', 'Initial') + + git(seed, 'checkout', '--quiet', '-b', 'develop') + commitFile(seed, 'develop.txt', 'work in progress\n', 'Develop moves on') + if (developConflicts) { + commitFile(seed, 'plugin.php', 'Version: 4.16.1-dev\n', 'Develop bumps its own version') + } + + git(seed, 'checkout', '--quiet', '-b', 'release/M26.test', 'main') + commitFile(seed, 'plugin.php', 'Version: 4.17.0\n', 'Prepare release/M26.test (4.17.0)') + + if (baseConflicts) { + git(seed, 'checkout', '--quiet', 'main') + commitFile(seed, 'plugin.php', 'Version: 4.16.0.1\n', 'Hotfix on main') + } + + git(seed, 'remote', 'add', 'origin', bare) + git(seed, 'push', '--quiet', 'origin', 'main', 'develop', 'release/M26.test') + + return bare +} + +/** + * The checkout the caller hands the action: a clone of the remote on its base branch, as + * actions/checkout leaves it. + * + * @param {string} bare The remote. + * @param {object} options + * @param {boolean} options.shallow Clone with `--depth 1`, the actions/checkout default. + * + * @returns {string} The checkout. + */ +function checkoutOf(bare, { shallow = false } = {}) { + const work = fs.mkdtempSync(path.join(os.tmpdir(), 'release-merge-checkout-')) + // A file:// URL rather than a path: git ignores --depth on a local-path clone. + const args = ['clone', '--quiet', '--branch', 'main', ...(shallow ? ['--depth', '1'] : []), `file://${bare}`, work] + + execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) + + return work +} + +/** + * The contents of a file on a branch of the remote. + * + * @param {string} bare The remote. + * @param {string} branch The branch. + * @param {string} name The file. + * + * @returns {string} Its contents. + */ +function fileOn(bare, branch, name) { + return git(bare, 'show', `${branch}:${name}`) +} + +/** + * The environment the manifest hands the merge step, over the real one so that git is on the PATH. + * + * @param {object} overrides Inputs to change. + * + * @returns {object} The environment. + */ +function mergeEnv(overrides = {}) { + return { + ...process.env, + INPUT_BRANCH: 'release/M26.test', + INPUT_VERSION: '4.17.0', + INPUT_BASE_BRANCH: 'main', + INPUT_DEVELOP_BRANCH: 'develop', + INPUT_TOKEN: 'test-token', + GITHUB_REPOSITORY: 'stellarwp/plugin', + GITHUB_SERVER_URL: 'https://github.com', + ...overrides, + } +} + +/** + * The environment the manifest hands the publish step. + * + * @param {object} overrides Inputs to change. + * + * @returns {object} The environment. + */ +function publishEnv(overrides = {}) { + return { + INPUT_VERSION: '4.17.0', + INPUT_BASE_BRANCH: 'main', + INPUT_PUBLISH: 'true', + GITHUB_REPOSITORY: 'stellarwp/plugin', + ...overrides, + } +} + +/** 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', + notes: '/repos/stellarwp/plugin/releases/generate-notes', + releases: '/repos/stellarwp/plugin/releases', +} + +const RELEASE_URL = 'https://github.com/stellarwp/plugin/releases/tag/4.17.0' + +/** Rules under which the version has never been tagged, the common case. */ +const UNTAGGED = [] + +/** Rules under which the version is tagged and its release published, as a retry finds. */ +const PUBLISHED = [ + { method: 'GET', path: API.tag, body: { ref: 'refs/tags/4.17.0', object: { sha: 'abc' } } }, + { method: 'GET', path: API.releaseByTag, body: { html_url: RELEASE_URL } }, +] + +/** + * Each call as `command arg arg…`, for asserting the order the action worked in. + * + * @param {object[]} calls The calls an exec recorded. + * + * @returns {string[]} One line per call. + */ +function sequence(calls) { + return calls.map((call) => [call.command, ...call.args].join(' ')) +} + +/** + * 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) { + return requests.map((request) => `${request.method} ${request.path}`) +} + +describe('release-merge', () => { + describe('validateVersion', () => { + it('accepts three or four numeric parts, the shape the dashboard publishes', () => { + assert.strictEqual(validateVersion('4.17.0'), '4.17.0') + assert.strictEqual(validateVersion('4.17.0.1'), '4.17.0.1') + }) + + it('reads the version without surrounding whitespace', () => { + assert.strictEqual(validateVersion(' 4.17.0\n'), '4.17.0') + }) + + it('refuses an empty version, saying the run was dispatched without one', () => { + assert.throws(() => validateVersion(''), /No version was given.*Nothing was merged/s) + assert.throws(() => validateVersion(undefined), /No version was given/) + }) + + it('refuses a pre-release, a leading v and fewer than three parts', () => { + for (const version of ['4.17.0-beta.1', 'v4.17.0', '4.17', '4.17.0.1.2', '4..0']) { + assert.throws(() => validateVersion(version), /three or four numeric parts.*Nothing was merged/s, version) + } + }) + }) + + describe('validateBranch', () => { + it('accepts a branch name', () => { + assert.strictEqual(validateBranch(' release/M26.test ', 'branch'), 'release/M26.test') + }) + + it('refuses an empty branch, naming the input', () => { + assert.throws(() => validateBranch('', 'develop-branch'), /develop-branch.*Nothing was merged/s) + }) + + it('refuses a full ref, naming the branch to pass instead', () => { + assert.throws(() => validateBranch('refs/heads/main', 'base-branch'), /pass the branch name.*'main'/s) + }) + }) + + describe('readPublish', () => { + it('reads an empty input and `true` as publish, the default', () => { + assert.strictEqual(readPublish(''), true) + assert.strictEqual(readPublish('true'), true) + assert.strictEqual(readPublish(' TRUE '), true) + }) + + it('reads `false` as merge only', () => { + assert.strictEqual(readPublish('false'), false) + }) + + it('refuses anything else, showing the two values', () => { + assert.throws(() => readPublish('yes'), /publish 'yes'.*true.*false/s) + }) + }) + + describe('the git credentials', () => { + it('builds the header actions/checkout would have stored, for the token', () => { + const expected = Buffer.from('x-access-token:test-token').toString('base64') + + assert.strictEqual(authorizationHeader('test-token'), `AUTHORIZATION: basic ${expected}`) + }) + + it('hands the header to git through its environment, scoped to the server', () => { + const env = gitAuthEnv('test-token', 'https://github.com') + + assert.deepStrictEqual(env, { + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader', + GIT_CONFIG_VALUE_0: authorizationHeader('test-token'), + }) + }) + + it('scopes the header to a GitHub Enterprise server when the run is on one', () => { + assert.strictEqual( + gitAuthEnv('t', 'https://github.example.com/').GIT_CONFIG_KEY_0, + 'http.https://github.example.com/.extraheader' + ) + }) + }) + + describe('mergeMessage', () => { + it('names what was merged into what', () => { + assert.strictEqual(mergeMessage('release/M26.test', 'main'), 'Merge release/M26.test into main') + }) + }) + + describe('isConflict', () => { + it('recognises the lines git prints on a conflict', () => { + assert.strictEqual( + isConflict({ + exitCode: 1, + stdout: 'Auto-merging plugin.php\nCONFLICT (content): Merge conflict in plugin.php\nAutomatic merge failed; fix conflicts and then commit the result.\n', + stderr: '', + }), + true + ) + }) + + it('does not read any other merge failure as a conflict', () => { + assert.strictEqual(isConflict({ exitCode: 128, stdout: '', stderr: 'fatal: not something we can merge\n' }), false) + assert.strictEqual(isConflict({ exitCode: 0, stdout: 'Already up to date.\n', stderr: '' }), false) + }) + }) + + describe('runPreflight', () => { + it('finds nothing when the version was never tagged', async () => { + const { github, requests } = await octokitFor(UNTAGGED) + + const result = await runPreflight({ github, owner: 'stellarwp', repo: 'plugin', version: '4.17.0' }) + + assert.deepStrictEqual(result, { releaseUrl: null }) + assert.deepStrictEqual(requested(requests), [`GET ${API.tag}`]) + }) + + it('finds the published release when the tag exists, so a retry can go on', async () => { + const { github, requests } = await octokitFor(PUBLISHED) + + const result = await runPreflight({ github, owner: 'stellarwp', repo: 'plugin', version: '4.17.0' }) + + assert.deepStrictEqual(result, { releaseUrl: RELEASE_URL }) + assert.deepStrictEqual(requested(requests), [`GET ${API.tag}`, `GET ${API.releaseByTag}`]) + }) + + it('refuses a tag that exists without a published release, before anything is merged', async () => { + const { github } = await octokitFor([PUBLISHED[0]]) + + await assert.rejects( + runPreflight({ github, owner: 'stellarwp', repo: 'plugin', version: '4.17.0' }), + /tag 4\.17\.0 already exists.*no published release.*Nothing was merged/s + ) + }) + + it('fails on an API error rather than reading it as an absent tag', async () => { + const { github } = await octokitFor([{ method: 'GET', path: API.tag, status: 500, body: { message: 'boom' } }]) + + await assert.rejects( + runPreflight({ github, owner: 'stellarwp', repo: 'plugin', version: '4.17.0' }), + /Reading tag 4\.17\.0 failed.*500/s + ) + }) + }) + + describe('runMerge', () => { + it('merges the release into the base, the base back into develop, and pushes both', async (t) => { + const bare = remoteWith() + const cwd = checkoutOf(bare) + const { github } = await octokitFor(UNTAGGED) + const { core, outputs, summary } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + + await runMerge({ github, core, exec, env: mergeEnv(), cwd }) + + assert.strictEqual(fileOn(bare, 'main', 'plugin.php'), 'Version: 4.17.0') + assert.strictEqual(fileOn(bare, 'develop', 'plugin.php'), 'Version: 4.17.0') + assert.strictEqual(fileOn(bare, 'develop', 'develop.txt'), 'work in progress') + assert.strictEqual(git(bare, 'log', '-1', '--format=%s', 'main'), 'Merge release/M26.test into main') + assert.strictEqual(git(bare, 'log', '-1', '--format=%s', 'develop'), 'Merge main back into develop (release/M26.test)') + assert.strictEqual(git(bare, 'log', '-1', '--format=%an <%ae>', 'main'), `${BOT_IDENTITY.name} <${BOT_IDENTITY.email}>`) + // A merge commit, never a fast-forward, so the release shows as one in the history. + assert.strictEqual(git(bare, 'rev-list', '--merges', '--count', 'main'), '1') + + assert.deepStrictEqual(outputs(), { + branch: 'release/M26.test', + version: '4.17.0', + 'base-branch': 'main', + 'develop-branch': 'develop', + conflict: '', + }) + assert.match(summary(), /release\/M26\.test.*main.*develop/s) + }) + + it('leaves the checkout on the merged base, for the steps that build from it', async (t) => { + const bare = remoteWith() + const cwd = checkoutOf(bare) + const { github } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + + await runMerge({ github, core, exec, env: mergeEnv(), cwd }) + + assert.strictEqual(git(cwd, 'symbolic-ref', '--short', 'HEAD'), 'main') + assert.strictEqual(git(cwd, 'rev-parse', 'HEAD'), git(bare, 'rev-parse', 'main')) + assert.strictEqual(git(cwd, 'status', '--porcelain'), '') + }) + + it('hands the token to git only for the fetch and the pushes, through the environment', async (t) => { + const bare = remoteWith() + const cwd = checkoutOf(bare) + const { github } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec, calls } = await recordedRealExec() + + await runMerge({ github, core, exec, env: mergeEnv(), cwd }) + + const remote = calls.filter((call) => /^(fetch|push)$/.test(call.args[0])) + const local = calls.filter((call) => !/^(fetch|push)$/.test(call.args[0])) + + assert.strictEqual(remote.length, 3) + for (const call of remote) { + assert.strictEqual(call.options.env.GIT_CONFIG_VALUE_0, authorizationHeader('test-token')) + } + for (const call of local) { + assert.strictEqual(call.options.env?.GIT_CONFIG_COUNT, undefined, sequence([call])[0]) + } + // Nothing was written to the checkout's config either: git exits 1 on a key that is not set. + assert.throws(() => git(cwd, 'config', '--local', '--get-all', 'http.https://github.com/.extraheader')) + }) + + it('does nothing on a retry after a late failure: the merges are no-ops and the pushes are empty', async (t) => { + const bare = remoteWith() + const first = checkoutOf(bare) + const { github } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + + await runMerge({ github, core, exec, env: mergeEnv(), cwd: first }) + const main = git(bare, 'rev-parse', 'main') + const develop = git(bare, 'rev-parse', 'develop') + + const second = checkoutOf(bare) + const retry = await coreWithOutputs(t) + await runMerge({ github, core: retry.core, exec, env: mergeEnv(), cwd: second }) + + assert.strictEqual(git(bare, 'rev-parse', 'main'), main) + assert.strictEqual(git(bare, 'rev-parse', 'develop'), develop) + assert.match(retry.logged(), /main already has release\/M26\.test/) + assert.strictEqual(retry.outputs().conflict, '') + }) + + it('says so when the release is already published, so the publish step is expected to skip it', async (t) => { + const bare = remoteWith() + const cwd = checkoutOf(bare) + const { github } = await octokitFor(PUBLISHED) + const { core, logged } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + + await runMerge({ github, core, exec, env: mergeEnv(), cwd }) + + assert.match(logged(), /4\.17\.0 is already published.*publish step will skip it/s) + }) + + it('prints the MERGE_CONFLICT marker and pushes nothing when the release conflicts with the base', async (t) => { + const bare = remoteWith({ baseConflicts: true }) + const cwd = checkoutOf(bare) + const main = git(bare, 'rev-parse', 'main') + const develop = git(bare, 'rev-parse', 'develop') + const { github } = await octokitFor(UNTAGGED) + const { core, outputs, logged } = await coreWithOutputs(t) + const { exec, calls } = await recordedRealExec() + + await assert.rejects( + runMerge({ github, core, exec, env: mergeEnv(), cwd }), + /release\/M26\.test into main stopped on a conflict.*Nothing was pushed to main/s + ) + + // The marker opens a line of its own: that is what the dashboard's detector anchors on. + assert.match(logged(), /^MERGE_CONFLICT: .*release\/M26\.test.*main/m) + assert.strictEqual(outputs().conflict, 'main') + assert.strictEqual(git(bare, 'rev-parse', 'main'), main) + assert.strictEqual(git(bare, 'rev-parse', 'develop'), develop) + assert.ok(!sequence(calls).some((line) => line.startsWith('git push')), 'nothing was pushed') + // The conflicted merge was abandoned, so the checkout is usable afterwards. + assert.strictEqual(git(cwd, 'status', '--porcelain'), '') + assert.throws(() => git(cwd, 'rev-parse', '--verify', '--quiet', 'MERGE_HEAD')) + }) + + it('prints the marker and leaves develop untouched when the base conflicts with develop', async (t) => { + const bare = remoteWith({ developConflicts: true }) + const cwd = checkoutOf(bare) + const develop = git(bare, 'rev-parse', 'develop') + const { github } = await octokitFor(UNTAGGED) + const { core, outputs, logged } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + + await assert.rejects( + runMerge({ github, core, exec, env: mergeEnv(), cwd }), + /main into develop stopped on a conflict.*Nothing was pushed to develop/s + ) + + assert.match(logged(), /^MERGE_CONFLICT: .*main.*develop/m) + assert.strictEqual(outputs().conflict, 'develop') + // The release did reach the base: a retry finds that merge done and only redoes develop. + assert.strictEqual(fileOn(bare, 'main', 'plugin.php'), 'Version: 4.17.0') + assert.strictEqual(git(bare, 'rev-parse', 'develop'), develop) + assert.strictEqual(git(cwd, 'status', '--porcelain'), '') + }) + + it('fetches the full history first when the caller made a shallow checkout', async (t) => { + const bare = remoteWith() + const cwd = checkoutOf(bare, { shallow: true }) + assert.strictEqual(git(cwd, 'rev-parse', '--is-shallow-repository'), 'true') + const { github } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec, calls } = await recordedRealExec() + + await runMerge({ github, core, exec, env: mergeEnv(), cwd }) + + assert.ok(sequence(calls).some((line) => line.startsWith('git fetch --unshallow')), sequence(calls).join('\n')) + assert.strictEqual(fileOn(bare, 'develop', 'plugin.php'), 'Version: 4.17.0') + }) + + it('refuses a tag without a release before touching git', async (t) => { + const bare = remoteWith() + const cwd = checkoutOf(bare) + const { github } = await octokitFor([PUBLISHED[0]]) + const { core } = await coreWithOutputs(t) + const { exec, calls } = await recordedRealExec() + + await assert.rejects(runMerge({ github, core, exec, env: mergeEnv(), cwd }), /no published release/) + + assert.ok(!sequence(calls).some((line) => /^git (fetch|merge|push|checkout)/.test(line)), sequence(calls).join('\n')) + }) + + it('refuses a branch name git would refuse, before asking the API anything', async (t) => { + const { github, requests } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec } = recordingExec([{ command: 'git', args: /^check-ref-format .*release\/bad\.\.name/, exitCode: 1 }, { command: 'git' }]) + + await assert.rejects( + runMerge({ github, core, exec, env: mergeEnv({ INPUT_BRANCH: 'release/bad..name' }), cwd: os.tmpdir() }), + /'release\/bad\.\.name' is not a valid branch name/ + ) + + assert.deepStrictEqual(requests, []) + }) + + it('refuses the same branch twice, which would merge a branch into itself', async (t) => { + const { github } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec([{ command: 'git' }]) + + await assert.rejects( + runMerge({ github, core, exec, env: mergeEnv({ INPUT_DEVELOP_BRANCH: 'main' }), cwd: os.tmpdir() }), + /base-branch and develop-branch are both 'main'/ + ) + assert.deepStrictEqual(calls, []) + }) + + it('fails when GITHUB_REPOSITORY is missing, since it does not know whose tags to read', async (t) => { + const { github } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec } = recordingExec([{ command: 'git' }]) + + await assert.rejects( + runMerge({ github, core, exec, env: mergeEnv({ GITHUB_REPOSITORY: '' }), cwd: os.tmpdir() }), + /GITHUB_REPOSITORY is not set/ + ) + }) + }) + + describe('runPublish', () => { + it('publishes the release on the base, titled with the version, with generated notes', async (t) => { + const { github, requests } = await octokitFor([ + { method: 'POST', path: API.notes, body: { name: '4.17.0', body: '## What changed\n* Fix (#12)' } }, + { method: 'POST', path: API.releases, status: 201, body: { html_url: RELEASE_URL } }, + ]) + const { core, outputs, summary } = await coreWithOutputs(t) + + await runPublish({ github, core, env: publishEnv() }) + + assert.deepStrictEqual(requested(requests), [`GET ${API.releaseByTag}`, `POST ${API.notes}`, `POST ${API.releases}`]) + assert.deepStrictEqual(requests[1].body, { tag_name: '4.17.0', target_commitish: 'main' }) + assert.deepStrictEqual(requests[2].body, { + tag_name: '4.17.0', + target_commitish: 'main', + name: '4.17.0', + body: '## What changed\n* Fix (#12)', + }) + assert.deepStrictEqual(outputs(), { published: 'true', 'release-url': RELEASE_URL }) + assert.match(summary(), /Published.*4\.17\.0/s) + }) + + it('skips a release that already exists, so a retry succeeds', async (t) => { + const { github, requests } = await octokitFor([PUBLISHED[1]]) + const { core, outputs, logged } = await coreWithOutputs(t) + + await runPublish({ github, core, env: publishEnv() }) + + assert.deepStrictEqual(requested(requests), [`GET ${API.releaseByTag}`]) + assert.deepStrictEqual(outputs(), { published: 'skipped', 'release-url': RELEASE_URL }) + assert.match(logged(), /4\.17\.0 is already published/) + }) + + it('publishes nothing when publish is false, and says so', async (t) => { + const { github, requests } = await octokitFor([]) + const { core, outputs, logged } = await coreWithOutputs(t) + + await runPublish({ github, core, env: publishEnv({ INPUT_PUBLISH: 'false' }) }) + + assert.deepStrictEqual(requests, []) + assert.deepStrictEqual(outputs(), { published: 'false', 'release-url': '' }) + assert.match(logged(), /publish is false/) + }) + + 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() }), /Reading the release for 4\.17\.0 failed.*500/s) + }) + + it('refuses a publish value that is neither true nor false', async (t) => { + const { github, requests } = await octokitFor([]) + const { core } = await coreWithOutputs(t) + + await assert.rejects(runPublish({ github, core, env: publishEnv({ INPUT_PUBLISH: 'yes' }) }), /publish 'yes'/) + assert.deepStrictEqual(requests, []) + }) + }) +}) From 1027c4ad4aa496bc5559f289927596c1aef37498 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Fri, 30 Oct 2026 13:44:42 -0300 Subject: [PATCH 03/12] Document the release-merge action and add a caller template --- .github/actions/release-merge/README.md | 147 ++++++++++++++++++++++++ README.md | 1 + templates/workflows/release-merge.yml | 66 +++++++++++ 3 files changed, 214 insertions(+) create mode 100644 .github/actions/release-merge/README.md create mode 100644 templates/workflows/release-merge.yml diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md new file mode 100644 index 0000000..50d83fc --- /dev/null +++ b/.github/actions/release-merge/README.md @@ -0,0 +1,147 @@ +# release-merge + +Merges a release and publishes it on GitHub: the release branch into the base branch, the base branch +back into develop, then the GitHub release for the version: + +```yaml +- uses: actions/checkout@v6 + with: + persist-credentials: false + +- uses: stellarwp/plugin-toolbox/.github/actions/release-merge@v1 + with: + branch: release/${{ inputs.release_name }} + version: ${{ inputs.version }} +``` + +It is the part of a deploy that is the same for every plugin repository on a `main` + `develop` +branch model. Building the plugin, uploading it anywhere and announcing it are the caller's steps, +after the action, on the checkout it leaves on the merged base branch. +[`templates/workflows/release-merge.yml`](../../../templates/workflows/release-merge.yml) is a +complete workflow around it, ready to copy into a plugin repository. + +## What it does + +In this order, stopping at the first failure: + +1. Checks the inputs, and the version against the repository's tags. See + [Versions it refuses](#versions-it-refuses). +2. Fetches the release branch, the base branch and develop from `origin`, with the full history + when the checkout is shallow. +3. Merges the release branch into the base branch as a merge commit, `Merge release/… into main`, + and pushes it. +4. Merges the **base branch** into develop as a merge commit, `Merge main back into develop + (release/…)`, and pushes it. The base branch rather than the release branch, so develop descends + from the base and consecutive releases bring one version bump each instead of divergent copies + of the same generated lines. +5. Publishes the GitHub release: tag and title are the version, target is the base branch, the notes + are the ones GitHub generates from the pull requests since the previous release. Skipped with + `publish: false`. + +The checkout is left on the base branch at the merged commit, clean. The merge commits are made by +`github-actions[bot]`; the pushes and the release are made by the identity of `token`, also +`github-actions[bot]` for the default token. + +## Running it again + +Every step is safe to run again after a late failure, which is how a retry from a release tool +works: + +- A branch that already holds the other merges as "Already up to date" and pushes nothing. +- A release that already exists is skipped, and its URL is still reported in `release-url`. +- 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. + +So a run that failed publishing, or after merging the base, is rerun as it is; nothing has to be +undone first. + +## When a merge conflicts + +The conflicted merge is abandoned (`git merge --abort`), nothing is pushed for that branch, and the +step fails with the branch named. The branches already pushed stay pushed: a conflict merging the +base into develop leaves the release on the base. Resolve the conflict on the branch by hand, push, +and run the step again. + +The log carries the line + +``` +MERGE_CONFLICT: merging main into develop conflicted. Resolve it on develop by hand and push, then run this step again. Nothing was pushed to develop. +``` + +and the `conflict` output names the branch. The [release +dashboard](https://github.com/stellarwp/nexcess-one) classifies the run from that line: it reads the +marker `MERGE_CONFLICT` at the start of an output line of the job log, every line of which the +runner prefixes with a timestamp. The marker is printed from the script, not written in the +workflow, so it is not in the step's echoed inputs and only appears when a merge did conflict. The +two lines git itself prints, `CONFLICT (…)` and `Automatic merge failed`, are in the log as well. + +## Versions it refuses + +- **Anything but three or four numeric parts**, such as `4.17.0` or `4.17.0.1`. A pre-release such + as `4.17.0-beta.1` and a leading `v` are refused: the version becomes the tag and the title of the + published release, and a beta or an RC is not released this way. It is the same rule the release + dashboard and release-prep apply, so a version that reaches this action has passed it already. +- **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 tag whose release is + published is a retry, and goes on. + +The branches are checked too: each has to be a name git accepts, the base and develop have to be +two different branches, and the release branch cannot be either of them. + +## Inputs + +| Input | Required | Default | What it is | +|---|---|---|---| +| `branch` | yes | | The release branch to merge, e.g. `release/M26.jigglypuff`. A branch name, fetched from `origin`. | +| `version` | yes | | The version being released: three or four numeric parts. | +| `base-branch` | no | `main` | The branch the release is merged into. `master` where that is the name. | +| `develop-branch` | no | `develop` | The branch the base is merged back into. | +| `publish` | no | `true` | `false` to do the two merges without the GitHub release. | +| `token` | no | `github.token` | The token for the pushes and the release. Needs `contents: write`. See [The token](#the-token). | + +## Outputs + +| Output | What it is | +|---|---| +| `version` | The version released. | +| `branch` | The release branch merged. | +| `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. | +| `published` | `true` when the release was created, `skipped` when it already existed, `false` when `publish` was false. | +| `release-url` | The release page, empty when `publish` was false. | + +## The token + +The job needs `contents: write`, and the token is used for the fetch, the two pushes and the release. +It is handed to git through the environment of those commands only (`GIT_CONFIG_*`, the +`http./.extraheader` header actions/checkout would store), so it is never in the checkout's +`.git/config` and never on a command line in the log. The checkout can and should use +`persist-credentials: false`, which matters when later steps of the same job run third-party +actions. + +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. That is on purpose. The run that merged +the release is the one that deploys it, so a repository that also has an `on: release` deploy does +not deploy it twice. A release that should trigger other workflows needs a token of its own. + +## The checkout + +The action runs in the caller's checkout, which can be on any branch and at any depth: the three +branches are fetched explicitly, and a shallow checkout is unshallowed first. Run it on a clean +checkout; it switches branches, and uncommitted changes would travel with it or stop it. + +The checkout's `origin` has to be the repository the run is on: `GITHUB_REPOSITORY` is where the +tags and the release are read and written. + +## How it runs + +Two [actions/github-script](https://github.com/actions/github-script) steps run +[`release-merge.js`](./release-merge.js): one for the checks and the merges, one for the release. The +inputs reach the script through environment variables, never interpolated into it, so a crafted +value is data rather than part of the program. The merges are git commands in the checkout; the tag +and release reads and the publish go through github-script's Octokit. + +The tests, `node --test` from the repository root, run the merges with real git against a bare +repository on disk and the API calls against a recorded client. diff --git a/README.md b/README.md index 3f7504d..a20099a 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,7 @@ 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, then the GitHub release, 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`. - [ ] 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-merge.yml b/templates/workflows/release-merge.yml new file mode 100644 index 0000000..e8dda2f --- /dev/null +++ b/templates/workflows/release-merge.yml @@ -0,0 +1,66 @@ +name: Release Merge + +# Merges a release and publishes it on GitHub: the release branch into the base branch, the base +# branch back into develop, then the GitHub release for the version. Nothing is built or deployed +# here; a plugin repository adds its own steps after the action, on the checkout it leaves on the +# merged base branch. +# +# 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 the interface a release tool dispatches: `release_name` and `version`. +# `production` is accepted because the release dashboard sends it for the deploy step; this +# workflow builds nothing, so it does not read it. +# +# Run it locally: you cannot, it pushes to two branches and publishes a release. Start it by hand with +# `gh workflow run release-merge.yml -f release_name=M26.jigglypuff -f version=4.17.0`. + +on: + workflow_dispatch: + inputs: + release_name: + description: 'Release name, the part after release/ in the branch, e.g. M26.jigglypuff' + required: true + type: string + version: + description: 'Version being released, e.g. 4.17.0' + required: true + type: string + production: + description: 'Sent by the release dashboard for the deploy step. Not read here' + required: false + type: string + default: 'true' + +# Two merges of the same release would race each other's pushes. One group per release makes the +# second wait for the first, which then finds nothing left to do. +concurrency: + group: ${{ github.workflow }}-${{ inputs.release_name }} + +permissions: + contents: read + +jobs: + merge: + 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 # the action pushes the merges and publishes the release + steps: + - uses: actions/checkout@v6 + with: + # The action 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: release + with: + branch: release/${{ inputs.release_name }} + version: ${{ inputs.version }} + # base-branch: master + + # The checkout is now on the merged base branch: build and deploy from here. From 8bce125202ba724d5b62eb7bbed067ba305272d2 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Fri, 30 Oct 2026 15:42:27 -0300 Subject: [PATCH 04/12] Refuse a published version whose tag does not contain the release branch A tag with a published release was read as a retry and the merges went on with the publish skipped. That is right when the release came from this branch, and wrong when the version went out earlier from another commit and the branch was never bumped past it: the new changes would land under a version that is already out. The tag is now fetched and the action goes on only when it contains the branch; otherwise it refuses before merging, naming the fix. --- .github/actions/release-merge/README.md | 14 ++++-- .github/actions/release-merge/action.yml | 3 +- .../actions/release-merge/release-merge.js | 46 ++++++++++++----- .../release-merge/release-merge.test.js | 49 +++++++++++++++++-- 4 files changed, 92 insertions(+), 20 deletions(-) diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md index 50d83fc..8e21c01 100644 --- a/.github/actions/release-merge/README.md +++ b/.github/actions/release-merge/README.md @@ -48,7 +48,9 @@ Every step is safe to run again after a late failure, which is how a retry from works: - A branch that already holds the other merges as "Already up to date" and pushes nothing. -- A release that already exists is skipped, and its URL is still reported in `release-url`. +- A release that already exists is skipped, and its URL is still reported in `release-url`. It + 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). - 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. @@ -85,8 +87,14 @@ two lines git itself prints, `CONFLICT (…)` and `Automatic merge failed`, are 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 tag whose release is - published is a retry, and goes on. + 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 + release's version. Merging it would put a second set of changes under a version that is already + out, so the action refuses, with the branches untouched. Give the release a new version and + prepare the branch again. A tag that does contain the branch is a retry of this release, and the + action goes on. The branches are checked too: each has to be a name git accepts, the base and develop have to be two different branches, and the release branch cannot be either of them. diff --git a/.github/actions/release-merge/action.yml b/.github/actions/release-merge/action.yml index c82c4f1..e002f36 100644 --- a/.github/actions/release-merge/action.yml +++ b/.github/actions/release-merge/action.yml @@ -19,7 +19,8 @@ inputs: The version being released: three or four numeric parts, such as 4.17.0 or 4.17.0.1, the same rule the release dashboard and release-prep apply. It becomes the tag and the title of the GitHub release. 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 without a published release. + anything is merged, and so is a version whose tag already exists, unless that tag's release was + published from this very branch, which is a retry. required: true base-branch: description: 'The branch the release is merged into: `main`, or `master` where that is the name.' diff --git a/.github/actions/release-merge/release-merge.js b/.github/actions/release-merge/release-merge.js index 66033ce..158b88f 100644 --- a/.github/actions/release-merge/release-merge.js +++ b/.github/actions/release-merge/release-merge.js @@ -187,9 +187,10 @@ function isNotFound(error) { * Checks the version against the repository's tags before anything is merged. * * A version that was never tagged is the common case. One whose tag exists with a published release - * is a retry after a late failure: the merges will find nothing to do and the publish step will skip - * it. One whose tag exists without a release would make the publish step reuse whatever commit the - * tag points at, so it is refused here, with the base and develop untouched. + * may be a retry after a late failure; runMerge settles that once the tag is fetched, by checking + * that the tag contains the branch. One whose tag exists without a release would make the publish + * step reuse whatever commit the tag points at, so it is refused here, with the base and develop + * untouched. * * @param {object} options * @param {object} options.github The Octokit github-script supplies. @@ -199,15 +200,15 @@ function isNotFound(error) { * * @throws {Error} When the tag exists without a release, or a read fails for any reason but 404. * - * @returns {Promise<{releaseUrl: string|null}>} The published release's URL, or null when the - * version was never tagged. + * @returns {Promise<{tagged: boolean, releaseUrl: string|null}>} Whether the tag exists, and the + * published release's URL when it does. */ async function runPreflight({ github, owner, repo, version }) { try { await github.rest.git.getRef({ owner, repo, ref: `tags/${version}` }) } catch (error) { if (isNotFound(error)) { - return { releaseUrl: null } + return { tagged: false, releaseUrl: null } } throw new Error(`Reading tag ${version} failed (HTTP ${error.status}): ${error.message}. Nothing was merged.`) @@ -216,7 +217,7 @@ async function runPreflight({ github, owner, repo, version }) { try { const { data: release } = await github.rest.repos.getReleaseByTag({ owner, repo, tag: version }) - return { releaseUrl: release.html_url } + return { tagged: true, releaseUrl: release.html_url } } catch (error) { if (isNotFound(error)) { throw new Error( @@ -384,10 +385,7 @@ async function runMerge({ github, core, exec, env, cwd = process.cwd() }) { } } - const { releaseUrl } = await runPreflight({ github, owner, repo, version }) - if (releaseUrl) { - core.info(`${version} is already published (${releaseUrl}); the publish step will skip it.`) - } + const { tagged, releaseUrl } = await runPreflight({ github, owner, repo, version }) const token = String(env.INPUT_TOKEN || '') const authEnv = { ...env, ...gitAuthEnv(token, env.GITHUB_SERVER_URL) } @@ -399,6 +397,9 @@ async function runMerge({ github, core, exec, env, cwd = process.cwd() }) { // in; `--unshallow` fetches it along with the branches. const shallow = (await exec.getExecOutput('git', ['rev-parse', '--is-shallow-repository'], { cwd, silent: true })).stdout.trim() const refspecs = [branch, base, develop].map((name) => `+refs/heads/${name}:refs/remotes/origin/${name}`) + if (tagged) { + refspecs.push(`+refs/tags/${version}:refs/tags/${version}`) + } await gitOrFail( exec, ['fetch', ...(shallow === 'true' ? ['--unshallow'] : []), 'origin', ...refspecs], @@ -406,6 +407,29 @@ async function runMerge({ github, core, exec, env, cwd = process.cwd() }) { 'Fetching the branches' ) + // A tag with a published release is a retry only when that release came from this branch: its + // commit then contains the branch, and the merges below find nothing to do. A tag that does not + // contain the branch is an earlier release of the same version, which the branch was never bumped + // past; merging it would put a second set of changes under a version that is already out. + if (tagged) { + const ancestry = await exec.getExecOutput( + 'git', + ['merge-base', '--is-ancestor', `origin/${branch}`, `refs/tags/${version}^{commit}`], + { cwd, ignoreReturnCode: true, silent: true } + ) + if (ancestry.exitCode === 1) { + throw new Error( + `The tag ${version} already exists and does not contain ${branch}: ${version} was released ` + + `from another commit, and the branch was never bumped past it. Give the release a new ` + + 'version and prepare the branch again. Nothing was merged.' + ) + } + if (ancestry.exitCode !== 0) { + throw new Error(`Comparing ${branch} with the tag ${version} failed (exit code ${ancestry.exitCode}). Nothing was merged.`) + } + core.info(`${version} is already published (${releaseUrl}) from ${branch}; the publish step will skip it.`) + } + await gitOrFail(exec, ['checkout', '--quiet', '-B', base, `origin/${base}`], { cwd }, `Checking out ${base}`) const mergedBase = await mergeAndPush({ core, diff --git a/tests/actions/release-merge/release-merge.test.js b/tests/actions/release-merge/release-merge.test.js index 8f1beda..24242d3 100644 --- a/tests/actions/release-merge/release-merge.test.js +++ b/tests/actions/release-merge/release-merge.test.js @@ -115,6 +115,17 @@ function checkoutOf(bare, { shallow = false } = {}) { return work } +/** + * Tags a commit on the remote, as publishing a GitHub release does. + * + * @param {string} bare The remote. + * @param {string} version The tag. + * @param {string} ref The commit to tag, e.g. `main`. + */ +function tagRemote(bare, version, ref) { + git(bare, 'tag', version, ref) +} + /** * The contents of a file on a branch of the remote. * @@ -315,7 +326,7 @@ describe('release-merge', () => { const result = await runPreflight({ github, owner: 'stellarwp', repo: 'plugin', version: '4.17.0' }) - assert.deepStrictEqual(result, { releaseUrl: null }) + assert.deepStrictEqual(result, { tagged: false, releaseUrl: null }) assert.deepStrictEqual(requested(requests), [`GET ${API.tag}`]) }) @@ -324,7 +335,7 @@ describe('release-merge', () => { const result = await runPreflight({ github, owner: 'stellarwp', repo: 'plugin', version: '4.17.0' }) - assert.deepStrictEqual(result, { releaseUrl: RELEASE_URL }) + assert.deepStrictEqual(result, { tagged: true, releaseUrl: RELEASE_URL }) assert.deepStrictEqual(requested(requests), [`GET ${API.tag}`, `GET ${API.releaseByTag}`]) }) @@ -423,10 +434,13 @@ describe('release-merge', () => { await runMerge({ github, core, exec, env: mergeEnv(), cwd: first }) const main = git(bare, 'rev-parse', 'main') const develop = git(bare, 'rev-parse', 'develop') + // The publish step then tagged the merge, so the retry finds the tag with its release. + tagRemote(bare, '4.17.0', 'main') + const published = await octokitFor(PUBLISHED) const second = checkoutOf(bare) const retry = await coreWithOutputs(t) - await runMerge({ github, core: retry.core, exec, env: mergeEnv(), cwd: second }) + await runMerge({ github: published.github, core: retry.core, exec, env: mergeEnv(), cwd: second }) assert.strictEqual(git(bare, 'rev-parse', 'main'), main) assert.strictEqual(git(bare, 'rev-parse', 'develop'), develop) @@ -434,8 +448,10 @@ describe('release-merge', () => { assert.strictEqual(retry.outputs().conflict, '') }) - it('says so when the release is already published, so the publish step is expected to skip it', async (t) => { + it('says so when the release is already published from this branch, so the publish step is expected to skip it', async (t) => { const bare = remoteWith() + // A run that merged and published, then failed in a later step of the caller. + tagRemote(bare, '4.17.0', 'release/M26.test') const cwd = checkoutOf(bare) const { github } = await octokitFor(PUBLISHED) const { core, logged } = await coreWithOutputs(t) @@ -444,6 +460,29 @@ describe('release-merge', () => { await runMerge({ github, core, exec, env: mergeEnv(), cwd }) assert.match(logged(), /4\.17\.0 is already published.*publish step will skip it/s) + assert.strictEqual(fileOn(bare, 'develop', 'plugin.php'), 'Version: 4.17.0') + }) + + it('refuses a published version whose tag does not contain the branch: the branch was not bumped', async (t) => { + const bare = remoteWith() + // 4.17.0 was released from main before this branch was cut, and the branch still says 4.17.0. + tagRemote(bare, '4.17.0', 'main') + const cwd = checkoutOf(bare) + const main = git(bare, 'rev-parse', 'main') + const develop = git(bare, 'rev-parse', 'develop') + const { github } = await octokitFor(PUBLISHED) + const { core } = await coreWithOutputs(t) + const { exec, calls } = await recordedRealExec() + + await assert.rejects( + runMerge({ github, core, exec, env: mergeEnv(), cwd }), + /tag 4\.17\.0 already exists and does not contain release\/M26\.test.*new version.*Nothing was merged/s + ) + + assert.strictEqual(git(bare, 'rev-parse', 'main'), main) + assert.strictEqual(git(bare, 'rev-parse', 'develop'), develop) + // `git -c user.name=… merge --no-ff` is the merge; merge-base is only the ancestry check. + assert.ok(!sequence(calls).some((line) => / merge --no-ff |^git push/.test(line)), sequence(calls).join('\n')) }) it('prints the MERGE_CONFLICT marker and pushes nothing when the release conflicts with the base', async (t) => { @@ -515,7 +554,7 @@ describe('release-merge', () => { await assert.rejects(runMerge({ github, core, exec, env: mergeEnv(), cwd }), /no published release/) - assert.ok(!sequence(calls).some((line) => /^git (fetch|merge|push|checkout)/.test(line)), sequence(calls).join('\n')) + assert.ok(!sequence(calls).some((line) => /^git (fetch|push|checkout)| merge --no-ff /.test(line)), sequence(calls).join('\n')) }) it('refuses a branch name git would refuse, before asking the API anything', async (t) => { From a74b1510af7b2d02c87d86f7e3a4260efadd6c8c Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Fri, 30 Oct 2026 16:03:34 -0300 Subject: [PATCH 05/12] Serialize the deploys of a repository in the caller template Two releases prepared with the same version and deployed at the same time would merge side by side, the second landing its changes under a version the first had just published. One concurrency group for the whole repository makes the second wait, and the action then refuses it on the published tag. The README says why the caller has to do this. --- .github/actions/release-merge/README.md | 19 +++++++++++++++++++ templates/workflows/release-merge.yml | 10 +++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md index 8e21c01..ba40324 100644 --- a/.github/actions/release-merge/README.md +++ b/.github/actions/release-merge/README.md @@ -120,6 +120,25 @@ two different branches, and the release branch cannot be either of them. | `published` | `true` when the release was created, `skipped` when it already existed, `false` when `publish` was false. | | `release-url` | The release page, empty when `publish` was false. | +## One deploy at a time + +Two releases prepared with the same version, deployed at the same time, would merge side by side: +the second's changes would land on the base under a version the first had just published, and its +publish would be skipped as already done. The action cannot stop that on its own, since both runs +start before either has a tag to find. The caller does, with a `concurrency` group shared by every +deploy of the repository, as the template sets: + +```yaml +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false +``` + +The second run then waits for the first, finds the version tagged from another branch, and is +refused before merging. A repository that also deploys from another workflow gives it the same +group. GitHub keeps one run waiting per group: a third deploy started while one is already waiting +cancels the waiting one, which has to be started again. + ## The token The job needs `contents: write`, and the token is used for the fetch, the two pushes and the release. diff --git a/templates/workflows/release-merge.yml b/templates/workflows/release-merge.yml index e8dda2f..affa87b 100644 --- a/templates/workflows/release-merge.yml +++ b/templates/workflows/release-merge.yml @@ -32,10 +32,14 @@ on: type: string default: 'true' -# Two merges of the same release would race each other's pushes. One group per release makes the -# second wait for the first, which then finds nothing left to do. +# 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 the action refuses it before merging. A repository that also deploys from another workflow +# (an `on: release` one, say) gives both the same group. concurrency: - group: ${{ github.workflow }}-${{ inputs.release_name }} + group: ${{ github.workflow }} + cancel-in-progress: false permissions: contents: read From f64b62dac13cdf687aa8c8f4a16ba94afecbd982 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Fri, 30 Oct 2026 16:24:29 -0300 Subject: [PATCH 06/12] Plain wording in the release-merge docs and the shared version helper --- .github/actions/release-merge/README.md | 9 ++++----- .github/actions/release-merge/action.yml | 4 ++-- .github/lib/versions.js | 2 +- templates/workflows/release-merge.yml | 2 +- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md index ba40324..3f50f15 100644 --- a/.github/actions/release-merge/README.md +++ b/.github/actions/release-merge/README.md @@ -44,8 +44,8 @@ The checkout is left on the base branch at the merged commit, clean. The merge c ## Running it again -Every step is safe to run again after a late failure, which is how a retry from a release tool -works: +Every step is safe to run again after a late failure, which is what a retry from a release tool +does: - A branch that already holds the other merges as "Already up to date" and pushes nothing. - A release that already exists is skipped, and its URL is still reported in `release-url`. It @@ -144,9 +144,8 @@ cancels the waiting one, which has to be started again. The job needs `contents: write`, and the token is used for the fetch, the two pushes and the release. It is handed to git through the environment of those commands only (`GIT_CONFIG_*`, the `http./.extraheader` header actions/checkout would store), so it is never in the checkout's -`.git/config` and never on a command line in the log. The checkout can and should use -`persist-credentials: false`, which matters when later steps of the same job run third-party -actions. +`.git/config` and never on a command line in the log. Use `persist-credentials: false` on the +checkout; it matters when later steps of the same job run third-party actions. 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. That is on purpose. The run that merged diff --git a/.github/actions/release-merge/action.yml b/.github/actions/release-merge/action.yml index e002f36..ac3f5ee 100644 --- a/.github/actions/release-merge/action.yml +++ b/.github/actions/release-merge/action.yml @@ -20,7 +20,7 @@ inputs: rule the release dashboard and release-prep apply. It becomes the tag and the title of the GitHub release. 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 very branch, which is a retry. + published from this branch, which makes the run a retry. required: true base-branch: description: 'The branch the release is merged into: `main`, or `master` where that is the name.' @@ -42,7 +42,7 @@ inputs: the job needs that permission too. It is handed to git through the environment of the fetch and the pushes only, never written to the checkout's config, so the checkout can use `persist-credentials: false`. A release published with the default token triggers no `release` - workflow, which is the point: the run that merged the release is the one that deploys it. + workflow, on purpose: the run that merged the release is the one that deploys it. required: false default: ${{ github.token }} diff --git a/.github/lib/versions.js b/.github/lib/versions.js index bc33847..2c350fe 100644 --- a/.github/lib/versions.js +++ b/.github/lib/versions.js @@ -5,7 +5,7 @@ * * One implementation lives here so every action orders versions the same way. The actions load * this file through a path relative to their own directory, so it ships inside the same - * repository checkout the runner already fetches for the action — there is nothing to publish + * repository checkout the runner already fetches for the action: there is nothing to publish * and nothing for a caller to install. */ diff --git a/templates/workflows/release-merge.yml b/templates/workflows/release-merge.yml index affa87b..5451f1b 100644 --- a/templates/workflows/release-merge.yml +++ b/templates/workflows/release-merge.yml @@ -36,7 +36,7 @@ on: # 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 the action refuses it before merging. A repository that also deploys from another workflow -# (an `on: release` one, say) gives both the same group. +# (an `on: release` workflow, for instance) gives both the same group. concurrency: group: ${{ github.workflow }} cancel-in-progress: false From 23b6a7178aeabad297e4c8ba0e481c248abf5226 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Fri, 30 Oct 2026 16:33:48 -0300 Subject: [PATCH 07/12] Share the repository, tag and command helpers between the actions readRepository, isNotFound and tagExists move to .github/lib/github.js and runOrFail to .github/lib/exec.js, so release-prep and release-merge read GITHUB_REPOSITORY, a tag and a command's failure the same way. The docs name the base branch as main or master wherever it comes up, say which rule release-prep applies and when, and note the two step outputs the action does not expose. --- .github/actions/release-merge/README.md | 52 +++++++------ .github/actions/release-merge/action.yml | 14 ++-- .../actions/release-merge/release-merge.js | 73 ++++--------------- .github/actions/release-prep/release-prep.js | 36 +-------- .github/lib/exec.js | 33 +++++++++ .github/lib/github.js | 68 +++++++++++++++++ templates/workflows/release-merge.yml | 5 +- 7 files changed, 158 insertions(+), 123 deletions(-) create mode 100644 .github/lib/exec.js create mode 100644 .github/lib/github.js diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md index 3f50f15..a43ec87 100644 --- a/.github/actions/release-merge/README.md +++ b/.github/actions/release-merge/README.md @@ -1,7 +1,8 @@ # release-merge -Merges a release and publishes it on GitHub: the release branch into the base branch, the base branch -back into develop, then the GitHub release for the version: +Merges a release and publishes it on GitHub: the release branch into the base branch, the base +branch back into develop, then the GitHub release for the version. **The base branch is `main`, or +`master` where that is the repository's name for it**, set with `base-branch`: ```yaml - uses: actions/checkout@v6 @@ -14,9 +15,9 @@ back into develop, then the GitHub release for the version: version: ${{ inputs.version }} ``` -It is the part of a deploy that is the same for every plugin repository on a `main` + `develop` -branch model. Building the plugin, uploading it anywhere and announcing it are the caller's steps, -after the action, on the checkout it leaves on the merged base branch. +It is the part of a deploy that is the same for every plugin repository on a base branch plus +`develop` model. Building the plugin, uploading it anywhere and announcing it are the caller's +steps, after the action, on the checkout it leaves on the merged base branch. [`templates/workflows/release-merge.yml`](../../../templates/workflows/release-merge.yml) is a complete workflow around it, ready to copy into a plugin repository. @@ -28,12 +29,12 @@ In this order, stopping at the first failure: [Versions it refuses](#versions-it-refuses). 2. Fetches the release branch, the base branch and develop from `origin`, with the full history when the checkout is shallow. -3. Merges the release branch into the base branch as a merge commit, `Merge release/… into main`, - and pushes it. +3. Merges the release branch into the base branch as a merge commit, `Merge release/… into main` + (or `into master`), and pushes it. 4. Merges the **base branch** into develop as a merge commit, `Merge main back into develop - (release/…)`, and pushes it. The base branch rather than the release branch, so develop descends - from the base and consecutive releases bring one version bump each instead of divergent copies - of the same generated lines. + (release/…)`, and pushes it. The base branch rather than the release branch, so develop + descends from the base and consecutive releases bring one version bump each instead of + divergent copies of the same generated lines. 5. Publishes the GitHub release: tag and title are the version, target is the base branch, the notes are the ones GitHub generates from the pull requests since the previous release. Skipped with `publish: false`. @@ -64,10 +65,11 @@ step fails with the branch named. The branches already pushed stay pushed: a con base into develop leaves the release on the base. Resolve the conflict on the branch by hand, push, and run the step again. -The log carries the line +The log carries this line, on one line: ``` -MERGE_CONFLICT: merging main into develop conflicted. Resolve it on develop by hand and push, then run this step again. Nothing was pushed to develop. +MERGE_CONFLICT: merging main into develop conflicted. Resolve it on develop by hand and push, +then run this step again. Nothing was pushed to develop. ``` and the `conflict` output names the branch. The [release @@ -81,11 +83,12 @@ two lines git itself prints, `CONFLICT (…)` and `Automatic merge failed`, are - **Anything but three or four numeric parts**, such as `4.17.0` or `4.17.0.1`. A pre-release such as `4.17.0-beta.1` and a leading `v` are refused: the version becomes the tag and the title of the - published release, and a beta or an RC is not released this way. It is the same rule the release - dashboard and release-prep apply, so a version that reaches this action has passed it already. + published release, and a beta or an RC is not released this way. The release dashboard validates + versions with the same rule, so a version it dispatches has passed it already; release-prep + does under the stellarwp changelog versioning. - **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. + 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 already released from another commit.** When the tag exists with a published release @@ -105,7 +108,7 @@ two different branches, and the release branch cannot be either of them. |---|---|---|---| | `branch` | yes | | The release branch to merge, e.g. `release/M26.jigglypuff`. A branch name, fetched from `origin`. | | `version` | yes | | The version being released: three or four numeric parts. | -| `base-branch` | no | `main` | The branch the release is merged into. `master` where that is the name. | +| `base-branch` | no | `main` | The branch the release is merged into: `main`, or `master` where that is the repository's name for it. | | `develop-branch` | no | `develop` | The branch the base is merged back into. | | `publish` | no | `true` | `false` to do the two merges without the GitHub release. | | `token` | no | `github.token` | The token for the pushes and the release. Needs `contents: write`. See [The token](#the-token). | @@ -120,6 +123,9 @@ two different branches, and the release branch cannot be either of them. | `published` | `true` when the release was created, `skipped` when it already existed, `false` when `publish` was false. | | `release-url` | The release page, empty when `publish` was false. | +The merge step also sets `base-branch` and `develop-branch` as step outputs, for the publish step; +the action does not expose them. + ## One deploy at a time Two releases prepared with the same version, deployed at the same time, would merge side by side: @@ -141,15 +147,15 @@ cancels the waiting one, which has to be started again. ## The token -The job needs `contents: write`, and the token is used for the fetch, the two pushes and the release. -It is handed to git through the environment of those commands only (`GIT_CONFIG_*`, the +The job needs `contents: write`, and the token is used for the fetch, the two pushes and the +release. It is handed to git through the environment of those commands only (`GIT_CONFIG_*`, the `http./.extraheader` header actions/checkout would store), so it is never in the checkout's `.git/config` and never on a command line in the log. Use `persist-credentials: false` on the checkout; it matters when later steps of the same job run third-party actions. 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. That is on purpose. The run that merged -the release is the one that deploys it, so a repository that also has an `on: release` deploy does +start workflows from events a workflow's own token caused. The run that merged the release is the +one that deploys it, so a repository that also has an `on: release` deploy does not deploy it twice. A release that should trigger other workflows needs a token of its own. ## The checkout @@ -164,8 +170,8 @@ tags and the release are read and written. ## How it runs Two [actions/github-script](https://github.com/actions/github-script) steps run -[`release-merge.js`](./release-merge.js): one for the checks and the merges, one for the release. The -inputs reach the script through environment variables, never interpolated into it, so a crafted +[`release-merge.js`](./release-merge.js): one for the checks and the merges, one for the release. +The inputs reach the script through environment variables, never interpolated into it, so a crafted value is data rather than part of the program. The merges are git commands in the checkout; the tag and release reads and the publish go through github-script's Octokit. diff --git a/.github/actions/release-merge/action.yml b/.github/actions/release-merge/action.yml index ac3f5ee..30a2b0e 100644 --- a/.github/actions/release-merge/action.yml +++ b/.github/actions/release-merge/action.yml @@ -1,5 +1,5 @@ -# Merges a release: the release branch into the base branch, the base branch back into develop, then -# publishes the GitHub release for the version. The part of a deploy that is the same for every +# Merges a release: the release branch into the base branch (`main`, or `master` where that is the +# name), the base branch back into develop, then publishes the GitHub release for the version. The part of a deploy that is the same for every # plugin; what happens after the merge (building, wordpress.org, announcements) is the caller's. # # The checks, the merges and the publish live in release-merge.js, run by actions/github-script, so @@ -16,9 +16,8 @@ inputs: required: true version: description: >- - The version being released: three or four numeric parts, such as 4.17.0 or 4.17.0.1, the same - rule the release dashboard and release-prep apply. It becomes the tag and the title of the - GitHub release. A pre-release such as 4.17.0-beta.1 and an empty value are refused before + The version being released: three or four numeric parts, such as 4.17.0 or 4.17.0.1, the rule + the release dashboard applies. It becomes the tag and the title of the GitHub release. 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. required: true @@ -42,7 +41,7 @@ inputs: the job needs that permission too. It is handed to git through the environment of the fetch and the pushes only, never written to the checkout's config, so the checkout can use `persist-credentials: false`. A release published with the default token triggers no `release` - workflow, on purpose: the run that merged the release is the one that deploys it. + workflow: the run that merged the release is the one that deploys it. required: false default: ${{ github.token }} @@ -96,7 +95,8 @@ runs: uses: actions/github-script@v9 env: ACTION_PATH: ${{ github.action_path }} - # The values the merge step validated, not the raw inputs. + # The values the merge step validated, not the raw inputs. base-branch and develop-branch + # are outputs of that step for this hand-over only; the action exposes neither. INPUT_VERSION: ${{ steps.merge.outputs.version }} INPUT_BASE_BRANCH: ${{ steps.merge.outputs.base-branch }} INPUT_PUBLISH: ${{ inputs.publish }} diff --git a/.github/actions/release-merge/release-merge.js b/.github/actions/release-merge/release-merge.js index 158b88f..f943910 100644 --- a/.github/actions/release-merge/release-merge.js +++ b/.github/actions/release-merge/release-merge.js @@ -1,8 +1,8 @@ 'use strict' /** - * Merges a release: the release branch into the base branch, the base branch back into develop, then - * publishes the GitHub release for the version. + * Merges a release: the release branch into the base branch (`main`, or `master` where that is the + * name), the base branch back into develop, then publishes the GitHub release for the version. * * The action runs in two github-script steps, so there are two entry points. runMerge checks the * inputs and the version against the repository's tags, then does the two merges with git in the @@ -16,6 +16,8 @@ */ const { STELLARWP_VERSION_PATTERN } = require('../../lib/versions.js') +const { readRepository, isNotFound, tagExists } = require('../../lib/github.js') +const { runOrFail } = require('../../lib/exec.js') /** The identity the merge commits are made with: the one GitHub gives the default token. */ const BOT_IDENTITY = { @@ -32,9 +34,9 @@ const CONFLICT_OUTPUT = /CONFLICT \(|Automatic merge failed/ /** * Reads the version to release, refusing anything but three or four numeric parts. * - * A pre-release is refused on purpose: the version becomes the tag and the title of a published - * release, and a beta or RC is not released this way. The same rule validates the version in the - * release dashboard and in release-prep, so a version that gets this far has already passed it twice. + * A pre-release is refused: the version becomes the tag and the title of a published release, and a + * beta or RC is not released this way. The release dashboard validates versions with this rule, and + * release-prep does under the stellarwp changelog versioning. * * @param {string} version The `version` input. * @@ -172,22 +174,11 @@ function isConflict(result) { return result.exitCode !== 0 && CONFLICT_OUTPUT.test(`${result.stdout}\n${result.stderr}`) } -/** - * Whether an Octokit error is the API saying a thing does not exist. - * - * @param {Error} error The error. - * - * @returns {boolean} True for a 404. - */ -function isNotFound(error) { - return error?.status === 404 -} - /** * Checks the version against the repository's tags before anything is merged. * * A version that was never tagged is the common case. One whose tag exists with a published release - * may be a retry after a late failure; runMerge settles that once the tag is fetched, by checking + * may be a retry after a late failure; runMerge decides that once the tag is fetched, by checking * that the tag contains the branch. One whose tag exists without a release would make the publish * step reuse whatever commit the tag points at, so it is refused here, with the base and develop * untouched. @@ -204,14 +195,8 @@ function isNotFound(error) { * published release's URL when it does. */ async function runPreflight({ github, owner, repo, version }) { - try { - await github.rest.git.getRef({ owner, repo, ref: `tags/${version}` }) - } catch (error) { - if (isNotFound(error)) { - return { tagged: false, releaseUrl: null } - } - - throw new Error(`Reading tag ${version} failed (HTTP ${error.status}): ${error.message}. Nothing was merged.`) + if (!(await tagExists({ github, owner, repo, version, what: 'Nothing was merged.' }))) { + return { tagged: false, releaseUrl: null } } try { @@ -233,26 +218,6 @@ async function runPreflight({ github, owner, repo, version }) { } } -/** - * Reads `GITHUB_REPOSITORY` as owner and name. - * - * @param {object} env The environment. - * @param {string} what What the action was about to do, to close the message. - * - * @throws {Error} When it is not set. - * - * @returns {{owner: string, repo: string}} The repository. - */ -function readRepository(env, what) { - const [owner, repo] = String(env.GITHUB_REPOSITORY || '').split('/') - - if (!owner || !repo) { - throw new Error(`GITHUB_REPOSITORY is not set, so the action does not know which repository to work on. ${what}`) - } - - return { owner, repo } -} - /** * Runs a git command in the checkout and fails with its output when it exits non-zero. * @@ -265,16 +230,8 @@ function readRepository(env, what) { * * @returns {Promise} What git printed to stdout. */ -async function gitOrFail(exec, args, options, what) { - const result = await exec.getExecOutput('git', args, { ...options, ignoreReturnCode: true }) - - if (result.exitCode !== 0) { - const output = [result.stdout, result.stderr].map((text) => text.trim()).filter(Boolean) - - throw new Error(`${what} failed (exit code ${result.exitCode}):\n${output.join('\n')}`) - } - - return result.stdout +function gitOrFail(exec, args, options, what) { + return runOrFail(exec, 'git', args, options, what) } /** @@ -471,9 +428,9 @@ async function runMerge({ github, core, exec, env, cwd = process.cwd() }) { /** * Publishes the GitHub release for the version, unless `publish` is false or it already exists. * - * The release is checked again here rather than read from the merge step: a retry, or another run - * publishing in between, finds the truth either way. Its notes are the ones GitHub generates from - * the pull requests since the previous release. + * The release is read again here rather than taken from the merge step's outputs, so a release + * created in between, by a retry or another run, is found. Its notes are the ones GitHub generates + * from the pull requests since the previous release. * * @param {object} options * @param {object} options.github The Octokit github-script supplies, authenticated with the token. diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index 0b2c134..c7e3411 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -18,6 +18,8 @@ const { execFileSync } = require('node:child_process') // The three-or-four-parts pattern lives with the other version rules, shared with release-merge. const { compareVersions, STELLARWP_VERSION_PATTERN } = require('../../lib/versions.js') +const { readRepository } = require('../../lib/github.js') +const { runOrFail } = require('../../lib/exec.js') /** * A semver version as semver.org defines it: MAJOR.MINOR.PATCH, an optional pre-release and @@ -617,31 +619,6 @@ function readCheckoutFile(cwd, name) { } } -/** - * Runs a command and fails with its output when it exits non-zero. - * - * @param {object} exec The @actions/exec toolkit. - * @param {string} command The program. - * @param {string[]} args Its arguments. - * @param {object} options Options for @actions/exec. - * @param {string} what What the command does, to open the error message. - * - * @throws {Error} When the command exits non-zero, holding what it printed. - * - * @returns {Promise} What the command printed to stdout. - */ -async function runOrFail(exec, command, args, options, what) { - const result = await exec.getExecOutput(command, args, { ...options, ignoreReturnCode: true }) - - if (result.exitCode !== 0) { - const output = [result.stdout, result.stderr].map((text) => text.trim()).filter(Boolean) - - throw new Error(`${what} failed (exit code ${result.exitCode}):\n${output.join('\n')}`) - } - - return result.stdout -} - /** * Validates the inputs, then bumps the version and replaces the TBDs with pup. * @@ -838,14 +815,7 @@ async function runCommit({ github, core, exec, env, cwd = process.cwd() }) { const branch = validateBranch(env.INPUT_BRANCH) const version = readVersion(env.INPUT_VERSION) const message = commitMessage(branch, version) - const [owner, repo] = String(env.GITHUB_REPOSITORY || '').split('/') - - if (!owner || !repo) { - throw new Error( - 'GITHUB_REPOSITORY is not set, so the action does not know which repository to commit to. ' + - 'Nothing was committed.' - ) - } + const { owner, repo } = readRepository(env, 'Nothing was committed.') await runOrFail(exec, 'git', ['add', '--all'], { cwd }, 'Staging the changes') diff --git a/.github/lib/exec.js b/.github/lib/exec.js new file mode 100644 index 0000000..613d3c9 --- /dev/null +++ b/.github/lib/exec.js @@ -0,0 +1,33 @@ +'use strict' + +/** + * Running commands through @actions/exec, shared by the toolbox actions. Loaded by a path relative to + * each action's directory, like versions.js. + */ + +/** + * Runs a command and fails with its output when it exits non-zero. + * + * @param {object} exec The @actions/exec toolkit. + * @param {string} command The program. + * @param {string[]} args Its arguments. + * @param {object} options Options for @actions/exec. + * @param {string} what What the command does, to open the error message. + * + * @throws {Error} When the command exits non-zero, holding what it printed. + * + * @returns {Promise} What the command printed to stdout. + */ +async function runOrFail(exec, command, args, options, what) { + const result = await exec.getExecOutput(command, args, { ...options, ignoreReturnCode: true }) + + if (result.exitCode !== 0) { + const output = [result.stdout, result.stderr].map((text) => text.trim()).filter(Boolean) + + throw new Error(`${what} failed (exit code ${result.exitCode}):\n${output.join('\n')}`) + } + + return result.stdout +} + +module.exports = { runOrFail } diff --git a/.github/lib/github.js b/.github/lib/github.js new file mode 100644 index 0000000..09a03df --- /dev/null +++ b/.github/lib/github.js @@ -0,0 +1,68 @@ +'use strict' + +/** + * Reads of the GitHub API shared by the toolbox actions, through the Octokit actions/github-script + * supplies. Loaded by a path relative to each action's directory, like versions.js. + */ + +/** + * Reads `GITHUB_REPOSITORY` as owner and name. + * + * @param {object} env The environment. + * @param {string} what What was not done, to close the message, e.g. `Nothing was merged.` + * + * @throws {Error} When it is not set. + * + * @returns {{owner: string, repo: string}} The repository. + */ +function readRepository(env, what) { + const [owner, repo] = String(env.GITHUB_REPOSITORY || '').split('/') + + if (!owner || !repo) { + throw new Error(`GITHUB_REPOSITORY is not set, so the action does not know which repository to work on. ${what}`) + } + + return { owner, repo } +} + +/** + * Whether an Octokit error is the API saying a thing does not exist. + * + * @param {Error} error The error. + * + * @returns {boolean} True for a 404. + */ +function isNotFound(error) { + return error?.status === 404 +} + +/** + * Reads a tag of the repository. + * + * @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.version The tag, a version. + * @param {string} options.what What was not done, to close the error message. + * + * @throws {Error} When the read fails for any reason but 404, so an API error is never taken for an + * absent tag. + * + * @returns {Promise} Whether the tag exists. + */ +async function tagExists({ github, owner, repo, version, what }) { + try { + await github.rest.git.getRef({ owner, repo, ref: `tags/${version}` }) + + return true + } catch (error) { + if (isNotFound(error)) { + return false + } + + throw new Error(`Reading tag ${version} failed (HTTP ${error.status}): ${error.message}. ${what}`) + } +} + +module.exports = { readRepository, isNotFound, tagExists } diff --git a/templates/workflows/release-merge.yml b/templates/workflows/release-merge.yml index 5451f1b..1f57d5f 100644 --- a/templates/workflows/release-merge.yml +++ b/templates/workflows/release-merge.yml @@ -1,7 +1,8 @@ name: Release Merge -# Merges a release and publishes it on GitHub: the release branch into the base branch, the base -# branch back into develop, then the GitHub release for the version. Nothing is built or deployed +# Merges a 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. Nothing is built or deployed # here; a plugin repository adds its own steps after the action, on the checkout it leaves on the # merged base branch. # From 2e083ba5ea857c20e8e2d687212482569064f8a1 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Fri, 30 Oct 2026 16:40:21 -0300 Subject: [PATCH 08/12] Rewrap the release-merge template's header comment --- templates/workflows/release-merge.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/templates/workflows/release-merge.yml b/templates/workflows/release-merge.yml index 1f57d5f..6524b35 100644 --- a/templates/workflows/release-merge.yml +++ b/templates/workflows/release-merge.yml @@ -2,9 +2,8 @@ name: Release Merge # Merges a 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. Nothing is built or deployed -# here; a plugin repository adds its own steps after the action, on the checkout it leaves on the -# merged base branch. +# version. Nothing is built or deployed here; a plugin repository adds its own steps after the +# action, on the checkout it leaves on the merged base branch. # # 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`. From 2e846852aef2704f143eab015cb584ddb12531c3 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Fri, 30 Oct 2026 17:33:52 -0300 Subject: [PATCH 09/12] Refuse a version the release branch does not hold The version becomes the tag and the title of the release, so it has to be the one release-prep wrote into the branch. The action now reads every file the branch's .puprc lists under paths.versions, with the regex pup uses for each, and refuses before merging when any holds a different version, when one holds none, or when the branch has no .puprc. The reader lives in .github/lib/puprc.js. --- .github/actions/release-merge/README.md | 9 ++- .github/actions/release-merge/action.yml | 9 ++- .../actions/release-merge/release-merge.js | 63 ++++++++++++++- .github/lib/puprc.js | 68 ++++++++++++++++ .../release-merge/release-merge.test.js | 78 ++++++++++++++++++- 5 files changed, 221 insertions(+), 6 deletions(-) create mode 100644 .github/lib/puprc.js diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md index a43ec87..00b82ed 100644 --- a/.github/actions/release-merge/README.md +++ b/.github/actions/release-merge/README.md @@ -28,7 +28,8 @@ In this order, stopping at the first failure: 1. Checks the inputs, and the version against the repository's tags. See [Versions it refuses](#versions-it-refuses). 2. Fetches the release branch, the base branch and develop from `origin`, with the full history - when the checkout is shallow. + when the checkout is shallow, and checks that every version file the branch's `.puprc` lists + holds the version. 3. Merges the release branch into the base branch as a merge commit, `Merge release/… into main` (or `into master`), and pushes it. 4. Merges the **base branch** into develop as a merge commit, `Merge main back into develop @@ -86,6 +87,12 @@ two lines git itself prints, `CONFLICT (…)` and `Automatic merge failed`, are published release, and a beta or an RC is not released this way. The release dashboard validates versions with the same rule, so a version it dispatches has passed it already; release-prep does under the stellarwp changelog versioning. +- **A version the branch does not hold.** The release branch's `.puprc` lists the files that carry the + version under `paths.versions`, with the regex that finds it in each, the same entries release-prep + bumps. The action reads every one of them on the branch and refuses when any holds a different + version, naming the file: the version becomes the tag, so it has to be the one the branch was + prepared with. A branch without a `.puprc`, or with a file where the regex finds nothing, is + refused too, since the version it holds cannot be read. - **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. diff --git a/.github/actions/release-merge/action.yml b/.github/actions/release-merge/action.yml index 30a2b0e..b201471 100644 --- a/.github/actions/release-merge/action.yml +++ b/.github/actions/release-merge/action.yml @@ -17,9 +17,12 @@ inputs: version: description: >- The version being released: three or four numeric parts, such as 4.17.0 or 4.17.0.1, the rule - the release dashboard applies. It becomes the tag and the title of the GitHub release. 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. + the release dashboard applies. It becomes the tag and the title of the GitHub release, so it has + to be the version the release branch holds: every file the branch's .puprc lists under + 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. required: true base-branch: description: 'The branch the release is merged into: `main`, or `master` where that is the name.' diff --git a/.github/actions/release-merge/release-merge.js b/.github/actions/release-merge/release-merge.js index f943910..78631cc 100644 --- a/.github/actions/release-merge/release-merge.js +++ b/.github/actions/release-merge/release-merge.js @@ -18,6 +18,7 @@ const { STELLARWP_VERSION_PATTERN } = require('../../lib/versions.js') const { readRepository, isNotFound, tagExists } = require('../../lib/github.js') const { runOrFail } = require('../../lib/exec.js') +const { readVersionFiles, versionIn } = require('../../lib/puprc.js') /** The identity the merge commits are made with: the one GitHub gives the default token. */ const BOT_IDENTITY = { @@ -218,6 +219,63 @@ async function runPreflight({ github, owner, repo, version }) { } } +/** + * Reads a file from a ref of the checkout, without checking it out. + * + * @param {object} exec The @actions/exec toolkit. + * @param {string} cwd The checkout. + * @param {string} ref The ref, e.g. `origin/release/M26.test`. + * @param {string} file The path in the repository. + * + * @returns {Promise} The contents, or null when the ref has no such file. + */ +async function fileAt(exec, cwd, ref, file) { + const result = await exec.getExecOutput('git', ['show', `${ref}:${file}`], { cwd, ignoreReturnCode: true, silent: true }) + + return result.exitCode === 0 ? result.stdout : null +} + +/** + * Checks that the version to release is the one the release branch holds, in every file .puprc lists + * under paths.versions. The tag and the title of the release come from `version`, so a value other + * than what release-prep wrote into the branch would publish the code under the wrong version. + * + * @param {object} options + * @param {object} options.exec The @actions/exec toolkit. + * @param {string} options.cwd The checkout. + * @param {string} options.branch The release branch, fetched as `origin/`. + * @param {string} options.version The version to release, validated. + * + * @throws {Error} When the branch has no usable .puprc, a version file holds no version, or any of + * them holds a different version. + * + * @returns {Promise} Resolves when every version file holds `version`. + */ +async function assertBranchHoldsVersion({ exec, cwd, branch, version }) { + const ref = `origin/${branch}` + const files = readVersionFiles(await fileAt(exec, cwd, ref, '.puprc'), 'Nothing was merged.') + + for (const { file, regex } of files) { + const content = await fileAt(exec, cwd, ref, file) + if (content === null) { + throw new Error(`${file}, which .puprc lists under paths.versions, is not on ${branch}. Nothing was merged.`) + } + + const found = versionIn(content, regex) + if (found === null) { + throw new Error( + `${file} on ${branch} holds no version where .puprc expects one (${regex}). Nothing was merged.` + ) + } + if (found !== version) { + throw new Error( + `${branch} holds ${found} in ${file}, not ${version}. The version to release has to be the one ` + + 'the branch was prepared with; check the version, or prepare the branch again. Nothing was merged.' + ) + } + } +} + /** * Runs a git command in the checkout and fails with its output when it exits non-zero. * @@ -295,7 +353,8 @@ async function mergeAndPush({ core, exec, cwd, authEnv, from, into, message, lab /** * Checks the inputs and the version, then merges the release branch into the base branch and the - * base branch back into develop, pushing each. + * base branch back into develop, pushing each. The version has to be the one the release branch + * holds in every file its .puprc lists, so the tag matches the code. * * The base branch, not the release branch, is merged into develop: develop then descends from the * base, and consecutive releases bring one version bump each instead of divergent copies of the same @@ -364,6 +423,8 @@ async function runMerge({ github, core, exec, env, cwd = process.cwd() }) { 'Fetching the branches' ) + await assertBranchHoldsVersion({ exec, cwd, branch, version }) + // A tag with a published release is a retry only when that release came from this branch: its // commit then contains the branch, and the merges below find nothing to do. A tag that does not // contain the branch is an earlier release of the same version, which the branch was never bumped diff --git a/.github/lib/puprc.js b/.github/lib/puprc.js new file mode 100644 index 0000000..74eb6cd --- /dev/null +++ b/.github/lib/puprc.js @@ -0,0 +1,68 @@ +'use strict' + +/** + * Reading the version files a repository's .puprc declares, shared by the toolbox actions. Loaded by a + * path relative to each action's directory, like versions.js. + * + * pup bumps the version in every file listed under `paths.versions`, each with a regex whose first + * group is the text before the version and whose second group is the version itself. The same + * entries let an action read the version a branch holds without running pup. + */ + +/** + * Reads the version files .puprc declares. + * + * @param {string|null} text The contents of .puprc, or null when the branch has none. + * @param {string} what What was not done, to close the error message, e.g. `Nothing was merged.` + * + * @throws {Error} When there is no .puprc, it is not JSON, or it declares no version files. + * + * @returns {{file: string, regex: string}[]} The files and the regex that finds the version in each. + */ +function readVersionFiles(text, what) { + if (text === null) { + throw new Error( + 'No .puprc on the branch, so the version its files hold cannot be read. The version files come ' + + `from paths.versions in .puprc; see the release-prep action's README. ${what}` + ) + } + + let config + try { + config = JSON.parse(text) + } catch (error) { + throw new Error(`.puprc is not valid JSON (${error.message}). ${what}`) + } + + const versions = config?.paths?.versions + if (!Array.isArray(versions) || versions.length === 0) { + throw new Error(`.puprc has no paths.versions, so there is no version file to read. ${what}`) + } + + for (const entry of versions) { + if (typeof entry?.file !== 'string' || typeof entry?.regex !== 'string') { + throw new Error(`.puprc lists a paths.versions entry without a file and a regex. ${what}`) + } + } + + return versions.map(({ file, regex }) => ({ file, regex })) +} + +/** + * The version a file holds, found with the regex .puprc gives for it: the second group, the way pup + * reads and rewrites it. + * + * @param {string} content The file. + * @param {string} regex The regex from .puprc, two groups: the text before the version, the version. + * + * @throws {Error} When the regex is not one JavaScript accepts. + * + * @returns {string|null} The version, trimmed, or null when the regex matches nothing. + */ +function versionIn(content, regex) { + const match = new RegExp(regex).exec(content) + + return match && match[2] !== undefined ? match[2].trim() : null +} + +module.exports = { readVersionFiles, versionIn } diff --git a/tests/actions/release-merge/release-merge.test.js b/tests/actions/release-merge/release-merge.test.js index 24242d3..0f460ff 100644 --- a/tests/actions/release-merge/release-merge.test.js +++ b/tests/actions/release-merge/release-merge.test.js @@ -61,10 +61,12 @@ function commitFile(dir, name, contents, message) { * merging the release into it conflicts. * @param {boolean} options.developConflicts Also move `develop` on that line, so merging `main` back * into it conflicts. + * @param {string} options.releaseReadme The readme.txt of the release branch; its Stable tag is + * the second version file .puprc lists. * * @returns {string} The bare repository. */ -function remoteWith({ baseConflicts = false, developConflicts = false } = {}) { +function remoteWith({ baseConflicts = false, developConflicts = false, releaseReadme = 'Stable tag: 4.17.0\n' } = {}) { const bare = fs.mkdtempSync(path.join(os.tmpdir(), 'release-merge-remote-')) const seed = fs.mkdtempSync(path.join(os.tmpdir(), 'release-merge-seed-')) @@ -73,6 +75,8 @@ function remoteWith({ baseConflicts = false, developConflicts = false } = {}) { git(seed, 'init', '--quiet', '-b', 'main') git(seed, 'config', 'user.name', 'Test') git(seed, 'config', 'user.email', 'test@example.com') + fs.writeFileSync(path.join(seed, '.puprc'), JSON.stringify(PUPRC)) + fs.writeFileSync(path.join(seed, 'readme.txt'), 'Stable tag: 4.16.0\n') commitFile(seed, 'plugin.php', 'Version: 4.16.0\n', 'Initial') git(seed, 'checkout', '--quiet', '-b', 'develop') @@ -82,6 +86,7 @@ function remoteWith({ baseConflicts = false, developConflicts = false } = {}) { } git(seed, 'checkout', '--quiet', '-b', 'release/M26.test', 'main') + fs.writeFileSync(path.join(seed, 'readme.txt'), releaseReadme) commitFile(seed, 'plugin.php', 'Version: 4.17.0\n', 'Prepare release/M26.test (4.17.0)') if (baseConflicts) { @@ -177,6 +182,16 @@ function publishEnv(overrides = {}) { } } +/** The .puprc of the fixture: the plugin header and the readme's Stable tag hold the version. */ +const PUPRC = { + paths: { + versions: [ + { file: 'plugin.php', regex: '(Version: )(.+)' }, + { file: 'readme.txt', regex: '(Stable tag: )(.+)' }, + ], + }, +} + /** 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', @@ -531,6 +546,67 @@ describe('release-merge', () => { assert.strictEqual(git(cwd, 'status', '--porcelain'), '') }) + it('refuses a version the branch does not hold, naming the file, before merging anything', async (t) => { + const bare = remoteWith() + const cwd = checkoutOf(bare) + const main = git(bare, 'rev-parse', 'main') + const { github } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec, calls } = await recordedRealExec() + + await assert.rejects( + runMerge({ github, core, exec, env: mergeEnv({ INPUT_VERSION: '4.18.0' }), cwd }), + /release\/M26\.test holds 4\.17\.0 in plugin\.php, not 4\.18\.0.*prepared with.*Nothing was merged/s + ) + + assert.strictEqual(git(bare, 'rev-parse', 'main'), main) + assert.ok(!sequence(calls).some((line) => / merge --no-ff |^git push/.test(line)), sequence(calls).join('\n')) + }) + + it('checks every version file .puprc lists, so a half-prepared branch is refused too', async (t) => { + const bare = remoteWith({ releaseReadme: 'Stable tag: 4.16.0\n' }) + const cwd = checkoutOf(bare) + const { github } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + + await assert.rejects( + runMerge({ github, core, exec, env: mergeEnv(), cwd }), + /release\/M26\.test holds 4\.16\.0 in readme\.txt, not 4\.17\.0/ + ) + }) + + it('refuses a branch whose version file does not match the .puprc regex', async (t) => { + const bare = remoteWith({ releaseReadme: 'No stable tag here\n' }) + const cwd = checkoutOf(bare) + const { github } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + + await assert.rejects( + runMerge({ github, core, exec, env: mergeEnv(), cwd }), + /readme\.txt on release\/M26\.test holds no version.*\(Stable tag: \)\(\.\+\)/s + ) + }) + + it('refuses a branch without a .puprc, since the version it holds cannot be read', async (t) => { + const bare = remoteWith() + // Remove .puprc from the release branch only. + const work = checkoutOf(bare) + git(work, 'checkout', '--quiet', 'release/M26.test') + git(work, 'config', 'user.name', 'Test') + git(work, 'config', 'user.email', 'test@example.com') + git(work, 'rm', '--quiet', '.puprc') + git(work, 'commit', '--quiet', '-m', 'Drop .puprc') + git(work, 'push', '--quiet', 'origin', 'release/M26.test') + const cwd = checkoutOf(bare) + const { github } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + + await assert.rejects(runMerge({ github, core, exec, env: mergeEnv(), cwd }), /No \.puprc on the branch.*Nothing was merged/s) + }) + it('fetches the full history first when the caller made a shallow checkout', async (t) => { const bare = remoteWith() const cwd = checkoutOf(bare, { shallow: true }) From 4779f4c5d3d10a113cc7559293c78ea934524c8c Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Fri, 30 Oct 2026 17:34:08 -0300 Subject: [PATCH 10/12] Rewrap the release-merge manifest's header comment --- .github/actions/release-merge/action.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/actions/release-merge/action.yml b/.github/actions/release-merge/action.yml index b201471..9b908df 100644 --- a/.github/actions/release-merge/action.yml +++ b/.github/actions/release-merge/action.yml @@ -1,10 +1,11 @@ # Merges a release: the release branch into the base branch (`main`, or `master` where that is the -# name), the base branch back into develop, then publishes the GitHub release for the version. The part of a deploy that is the same for every -# plugin; what happens after the merge (building, wordpress.org, announcements) is the caller's. +# name), the base branch back into develop, then publishes the GitHub release for the version. The +# part of a deploy that is the same for every plugin; what happens after the merge (building, +# wordpress.org, announcements) is the caller's. # # The checks, the merges and the publish live in release-merge.js, run by actions/github-script, so -# there is no dependency to install and nothing to build. The merges are git in the caller's checkout, -# the tag and release reads and the publish go through the GitHub API. +# there is no dependency to install and nothing to build. The merges are git in the caller's +# checkout; the tag and release reads and the publish go through the GitHub API. name: Release merge description: Merges the release branch into the base and the base back into develop, then publishes the GitHub release From d2cf03aa21cafe0472b95b70e058bd1e2fb30e98 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Fri, 30 Oct 2026 17:41:50 -0300 Subject: [PATCH 11/12] Take the release branch whole in the caller template The release dashboard now sends the deploy step the branch, as it sends prepare, so the template declares branch and passes it to the action as it is. --- .github/actions/release-merge/README.md | 2 +- templates/workflows/release-merge.yml | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md index 00b82ed..8171112 100644 --- a/.github/actions/release-merge/README.md +++ b/.github/actions/release-merge/README.md @@ -11,7 +11,7 @@ branch back into develop, then the GitHub release for the version. **The base br - uses: stellarwp/plugin-toolbox/.github/actions/release-merge@v1 with: - branch: release/${{ inputs.release_name }} + branch: ${{ inputs.branch }} version: ${{ inputs.version }} ``` diff --git a/templates/workflows/release-merge.yml b/templates/workflows/release-merge.yml index 6524b35..6974495 100644 --- a/templates/workflows/release-merge.yml +++ b/templates/workflows/release-merge.yml @@ -8,18 +8,18 @@ name: Release Merge # 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 the interface a release tool dispatches: `release_name` and `version`. -# `production` is accepted because the release dashboard sends it for the deploy step; this -# workflow builds nothing, so it does not read it. +# The inputs are the interface a release tool dispatches: `branch` and `version`. `production` is +# accepted because the release dashboard sends it for the deploy step; this workflow builds nothing, +# so it does not read it. # # Run it locally: you cannot, it pushes to two branches and publishes a release. Start it by hand with -# `gh workflow run release-merge.yml -f release_name=M26.jigglypuff -f version=4.17.0`. +# `gh workflow run release-merge.yml -f branch=release/M26.jigglypuff -f version=4.17.0`. on: workflow_dispatch: inputs: - release_name: - description: 'Release name, the part after release/ in the branch, e.g. M26.jigglypuff' + branch: + description: 'Release branch to merge, e.g. release/M26.jigglypuff' required: true type: string version: @@ -63,7 +63,7 @@ jobs: - uses: stellarwp/plugin-toolbox/.github/actions/release-merge@v1 id: release with: - branch: release/${{ inputs.release_name }} + branch: ${{ inputs.branch }} version: ${{ inputs.version }} # base-branch: master From b066fe625594441f52fc114c1d3accffa0132861 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Fri, 30 Oct 2026 18:36:38 -0300 Subject: [PATCH 12/12] Check the publish input before merging and name the file behind a regex JavaScript rejects A publish value that is neither true nor false failed only in the publish step, after both merges were pushed; the merge step reads it now too, so the run stops before anything is merged. A .puprc regex pup accepts but JavaScript does not surfaced as a bare SyntaxError; the error now names the file and the regex. The README says where in the sequence the tag ancestry check runs. --- .github/actions/release-merge/README.md | 5 ++- .github/actions/release-merge/action.yml | 2 + .../actions/release-merge/release-merge.js | 12 +++++- .../release-merge/release-merge.test.js | 37 ++++++++++++++++++- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md index 8171112..1fb6b99 100644 --- a/.github/actions/release-merge/README.md +++ b/.github/actions/release-merge/README.md @@ -28,8 +28,9 @@ In this order, stopping at the first failure: 1. Checks the inputs, and the version against the repository's tags. See [Versions it refuses](#versions-it-refuses). 2. Fetches the release branch, the base branch and develop from `origin`, with the full history - when the checkout is shallow, and checks that every version file the branch's `.puprc` lists - holds the version. + when the checkout is shallow. Then it checks that every version file the branch's `.puprc` lists + holds the version and, when the version is already tagged, that the tag contains the branch: a + retry of this release goes on, a tag made from another commit is refused. 3. Merges the release branch into the base branch as a merge commit, `Merge release/… into main` (or `into master`), and pushes it. 4. Merges the **base branch** into develop as a merge commit, `Merge main back into develop diff --git a/.github/actions/release-merge/action.yml b/.github/actions/release-merge/action.yml index 9b908df..e04fdcb 100644 --- a/.github/actions/release-merge/action.yml +++ b/.github/actions/release-merge/action.yml @@ -86,6 +86,8 @@ runs: INPUT_VERSION: ${{ inputs.version }} INPUT_BASE_BRANCH: ${{ inputs.base-branch }} INPUT_DEVELOP_BRANCH: ${{ inputs.develop-branch }} + # Checked here too, so a wrong value fails before anything is merged. + INPUT_PUBLISH: ${{ inputs.publish }} INPUT_TOKEN: ${{ inputs.token }} with: # The tag and release reads go through github-script's own client, built from this token. diff --git a/.github/actions/release-merge/release-merge.js b/.github/actions/release-merge/release-merge.js index 78631cc..ceb7671 100644 --- a/.github/actions/release-merge/release-merge.js +++ b/.github/actions/release-merge/release-merge.js @@ -261,7 +261,15 @@ async function assertBranchHoldsVersion({ exec, cwd, branch, version }) { throw new Error(`${file}, which .puprc lists under paths.versions, is not on ${branch}. Nothing was merged.`) } - const found = versionIn(content, regex) + let found + try { + found = versionIn(content, regex) + } catch (error) { + throw new Error( + `The regex .puprc gives for ${file} (${regex}) is not one JavaScript accepts: ${error.message}. ` + + 'Nothing was merged.' + ) + } if (found === null) { throw new Error( `${file} on ${branch} holds no version where .puprc expects one (${regex}). Nothing was merged.` @@ -381,6 +389,8 @@ async function runMerge({ github, core, exec, env, cwd = process.cwd() }) { const version = validateVersion(env.INPUT_VERSION) const base = validateBranch(env.INPUT_BASE_BRANCH, 'base-branch') const develop = validateBranch(env.INPUT_DEVELOP_BRANCH, 'develop-branch') + // Read by the publish step; checked here so a wrong value fails before anything is merged. + readPublish(env.INPUT_PUBLISH) const { owner, repo } = readRepository(env, 'Nothing was merged.') if (base === develop) { diff --git a/tests/actions/release-merge/release-merge.test.js b/tests/actions/release-merge/release-merge.test.js index 0f460ff..ffc628f 100644 --- a/tests/actions/release-merge/release-merge.test.js +++ b/tests/actions/release-merge/release-merge.test.js @@ -63,10 +63,16 @@ function commitFile(dir, name, contents, message) { * into it conflicts. * @param {string} options.releaseReadme The readme.txt of the release branch; its Stable tag is * the second version file .puprc lists. + * @param {object} options.puprc The .puprc every branch carries. * * @returns {string} The bare repository. */ -function remoteWith({ baseConflicts = false, developConflicts = false, releaseReadme = 'Stable tag: 4.17.0\n' } = {}) { +function remoteWith({ + baseConflicts = false, + developConflicts = false, + releaseReadme = 'Stable tag: 4.17.0\n', + puprc = PUPRC, +} = {}) { const bare = fs.mkdtempSync(path.join(os.tmpdir(), 'release-merge-remote-')) const seed = fs.mkdtempSync(path.join(os.tmpdir(), 'release-merge-seed-')) @@ -75,7 +81,7 @@ function remoteWith({ baseConflicts = false, developConflicts = false, releaseRe git(seed, 'init', '--quiet', '-b', 'main') git(seed, 'config', 'user.name', 'Test') git(seed, 'config', 'user.email', 'test@example.com') - fs.writeFileSync(path.join(seed, '.puprc'), JSON.stringify(PUPRC)) + fs.writeFileSync(path.join(seed, '.puprc'), JSON.stringify(puprc)) fs.writeFileSync(path.join(seed, 'readme.txt'), 'Stable tag: 4.16.0\n') commitFile(seed, 'plugin.php', 'Version: 4.16.0\n', 'Initial') @@ -589,6 +595,33 @@ describe('release-merge', () => { ) }) + it('names the file when the .puprc regex is not one JavaScript accepts', async (t) => { + const bare = remoteWith({ puprc: { paths: { versions: [{ file: 'plugin.php', regex: '(Version: )(?<' }] } } }) + const cwd = checkoutOf(bare) + const { github } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec, calls } = await recordedRealExec() + + await assert.rejects( + runMerge({ github, core, exec, env: mergeEnv(), cwd }), + /regex \.puprc gives for plugin\.php \(\(Version: \)\(\?<\) is not one JavaScript accepts.*Nothing was merged/s + ) + assert.ok(!sequence(calls).some((line) => / merge --no-ff |^git push/.test(line))) + }) + + it('refuses a publish value that is neither true nor false before touching git', async (t) => { + const { github, requests } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec([{ command: 'git' }]) + + await assert.rejects( + runMerge({ github, core, exec, env: mergeEnv({ INPUT_PUBLISH: 'yes' }), cwd: os.tmpdir() }), + /publish 'yes'/ + ) + assert.deepStrictEqual(calls, []) + assert.deepStrictEqual(requests, []) + }) + it('refuses a branch without a .puprc, since the version it holds cannot be read', async (t) => { const bare = remoteWith() // Remove .puprc from the release branch only.