From ef939b9751aacae656b6b0c8468acbbebdca7931 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Fri, 30 Oct 2026 13:44:42 -0300 Subject: [PATCH 01/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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. From 7ed338804cd12b765593e09ff8f19c8732f6e855 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 16:25:28 -0300 Subject: [PATCH 13/23] Publish the release at the commit the merge left on the base, not at the branch's tip createRelease took the base branch's name as target_commitish, so the tag was made at whatever the base pointed to when the publish step ran: a pull request merged into the base between the push and the publish would have been tagged instead of the release. The merge step now reads the commit it left the base on and hands it to the publish step, which tags that commit. It is also an output of the action, base-sha, since it is the commit the caller builds from. SOFT-5188 --- .github/actions/release-merge/README.md | 10 +++-- .github/actions/release-merge/action.yml | 10 ++++- .../actions/release-merge/release-merge.js | 45 ++++++++++++++++--- .../release-merge/release-merge.test.js | 37 +++++++++++++-- 4 files changed, 88 insertions(+), 14 deletions(-) diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md index 1fb6b99..4ad3037 100644 --- a/.github/actions/release-merge/README.md +++ b/.github/actions/release-merge/README.md @@ -37,9 +37,10 @@ In this order, stopping at the first failure: (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`. +5. Publishes the GitHub release: tag and title are the version, the tag is made at the commit step + 3 left the base branch on (the merge commit, so a pull request merged into the base meanwhile is + not what gets tagged), 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 @@ -127,12 +128,13 @@ two different branches, and the release branch cannot be either of them. |---|---| | `version` | The version released. | | `branch` | The release branch merged. | +| `base-sha` | The commit on the base branch that holds the release: the merge commit, or the base's tip on a retry that found the merge done. The release is published at it, and the checkout is left on it. | | `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 merge step also sets `base-branch` and `develop-branch` as step outputs, for the publish step; -the action does not expose them. +the action does not expose them. `base-sha` is set the same way and is also an output of the action. ## One deploy at a time diff --git a/.github/actions/release-merge/action.yml b/.github/actions/release-merge/action.yml index e04fdcb..79487d2 100644 --- a/.github/actions/release-merge/action.yml +++ b/.github/actions/release-merge/action.yml @@ -56,6 +56,12 @@ outputs: branch: description: 'The release branch merged.' value: ${{ steps.merge.outputs.branch }} + base-sha: + description: >- + The commit on the base branch that holds the release: the merge commit, or the base's tip on a + retry that found the merge already done. The release is published at this commit, and it is the + commit the checkout is left on. + value: ${{ steps.merge.outputs.base-sha }} conflict: description: >- Which branch a merge conflicted on, `main` or `develop` for the defaults, or empty when both @@ -102,9 +108,11 @@ runs: env: ACTION_PATH: ${{ github.action_path }} # 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. + # are outputs of that step for this hand-over only; the action exposes neither. base-sha is + # the commit the release is published at. INPUT_VERSION: ${{ steps.merge.outputs.version }} INPUT_BASE_BRANCH: ${{ steps.merge.outputs.base-branch }} + INPUT_BASE_SHA: ${{ steps.merge.outputs.base-sha }} INPUT_PUBLISH: ${{ inputs.publish }} with: github-token: ${{ inputs.token }} diff --git a/.github/actions/release-merge/release-merge.js b/.github/actions/release-merge/release-merge.js index ceb7671..35b70cc 100644 --- a/.github/actions/release-merge/release-merge.js +++ b/.github/actions/release-merge/release-merge.js @@ -94,6 +94,28 @@ function validateBranch(branch, input) { return trimmed } +/** + * Reads the commit the release is published at, the merge step's `base-sha` output. + * + * @param {string} sha The output value. + * + * @throws {Error} When it is not a full commit hash. + * + * @returns {string} The hash. + */ +function validateSha(sha) { + const trimmed = String(sha ?? '').trim() + + if (!/^[0-9a-f]{40}$/.test(trimmed)) { + throw new Error( + `'${trimmed}' is not the commit to publish at: expected the merge step's base-sha output, a full ` + + 'commit hash. Nothing was published.' + ) + } + + return trimmed +} + /** * Reads the `publish` input. * @@ -470,6 +492,11 @@ async function runMerge({ github, core, exec, env, cwd = process.cwd() }) { label: branch, }) + // The commit the release is published at: the merge commit, or the base's tip on a retry that + // found the merge done. The publish step tags this commit, not whatever the base branch points + // at when it runs, and it is also the commit the caller builds from. + const baseSha = (await gitOrFail(exec, ['rev-parse', 'HEAD'], { cwd, silent: true }, `Reading the merged ${base} commit`)).trim() + await gitOrFail(exec, ['checkout', '--quiet', '-B', develop, `origin/${develop}`], { cwd }, `Checking out ${develop}`) const mergedDevelop = await mergeAndPush({ core, @@ -487,6 +514,7 @@ async function runMerge({ github, core, exec, env, cwd = process.cwd() }) { core.setOutput('branch', branch) core.setOutput('version', version) core.setOutput('base-branch', base) + core.setOutput('base-sha', baseSha) core.setOutput('develop-branch', develop) core.setOutput('conflict', '') @@ -508,7 +536,8 @@ async function runMerge({ github, core, exec, env, cwd = process.cwd() }) { * @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. + * @throws {Error} When `publish` is not true or false, the merged commit is missing, or an API call + * fails. * * @returns {Promise} Resolves once the release exists, or was found unnecessary. */ @@ -516,6 +545,7 @@ 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') + const sha = validateSha(env.INPUT_BASE_SHA) if (!publish) { core.info(`publish is false: the GitHub release for ${version} was not created.`) @@ -547,25 +577,29 @@ async function runPublish({ github, core, env }) { return } + // The tag is made at the commit the merge step left the base on, so it points at the release + // even if the base branch moved while this step ran. const { data: notes } = await github.rest.repos.generateReleaseNotes({ owner, repo, tag_name: version, - target_commitish: base, + target_commitish: sha, }) const { data: release } = await github.rest.repos.createRelease({ owner, repo, tag_name: version, - target_commitish: base, + target_commitish: sha, name: version, body: notes.body, }) - core.info(`Published ${version}: ${release.html_url}`) + core.info(`Published ${version} at ${sha.slice(0, 7)}: ${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() + await core.summary + .addRaw(`Published release ${version} on \`${base}\` at \`${sha.slice(0, 7)}\`: ${release.html_url}`, true) + .write() } module.exports = { @@ -574,6 +608,7 @@ module.exports = { runPreflight, validateVersion, validateBranch, + validateSha, readPublish, authorizationHeader, gitAuthEnv, diff --git a/tests/actions/release-merge/release-merge.test.js b/tests/actions/release-merge/release-merge.test.js index ffc628f..e43b970 100644 --- a/tests/actions/release-merge/release-merge.test.js +++ b/tests/actions/release-merge/release-merge.test.js @@ -13,6 +13,7 @@ const { runPreflight, validateVersion, validateBranch, + validateSha, readPublish, authorizationHeader, gitAuthEnv, @@ -182,12 +183,16 @@ function publishEnv(overrides = {}) { return { INPUT_VERSION: '4.17.0', INPUT_BASE_BRANCH: 'main', + INPUT_BASE_SHA: MERGED_SHA, INPUT_PUBLISH: 'true', GITHUB_REPOSITORY: 'stellarwp/plugin', ...overrides, } } +/** The merge commit the merge step hands the publish step, as `base-sha`. */ +const MERGED_SHA = '0123456789abcdef0123456789abcdef01234567' + /** The .puprc of the fixture: the plugin header and the readme's Stable tag hold the version. */ const PUPRC = { paths: { @@ -276,6 +281,15 @@ describe('release-merge', () => { }) }) + describe('validateSha', () => { + it('takes a full commit hash and nothing else', () => { + assert.strictEqual(validateSha(` ${MERGED_SHA} `), MERGED_SHA) + for (const value of ['', 'main', MERGED_SHA.slice(0, 7), MERGED_SHA.toUpperCase()]) { + assert.throws(() => validateSha(value), /not the commit to publish at/) + } + }) + }) + describe('readPublish', () => { it('reads an empty input and `true` as publish, the default', () => { assert.strictEqual(readPublish(''), true) @@ -402,6 +416,8 @@ describe('release-merge', () => { branch: 'release/M26.test', version: '4.17.0', 'base-branch': 'main', + // The merge commit on the remote: what the publish step tags and the caller builds from. + 'base-sha': git(bare, 'rev-parse', 'main'), 'develop-branch': 'develop', conflict: '', }) @@ -467,6 +483,8 @@ describe('release-merge', () => { assert.strictEqual(git(bare, 'rev-parse', 'develop'), develop) assert.match(retry.logged(), /main already has release\/M26\.test/) assert.strictEqual(retry.outputs().conflict, '') + // Nothing was merged, so the commit to publish at is the base's tip, the one holding the release. + assert.strictEqual(retry.outputs()['base-sha'], main) }) it('says so when the release is already published from this branch, so the publish step is expected to skip it', async (t) => { @@ -704,7 +722,7 @@ describe('release-merge', () => { }) describe('runPublish', () => { - it('publishes the release on the base, titled with the version, with generated notes', async (t) => { + it('publishes the release at the merged commit, 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 } }, @@ -714,15 +732,17 @@ describe('release-merge', () => { 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' }) + // The merge step's commit, not the base branch's name: a commit pushed to main between the + // merge and this step is not what gets tagged. + assert.deepStrictEqual(requests[1].body, { tag_name: '4.17.0', target_commitish: MERGED_SHA }) assert.deepStrictEqual(requests[2].body, { tag_name: '4.17.0', - target_commitish: 'main', + target_commitish: MERGED_SHA, 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) + assert.match(summary(), /Published.*4\.17\.0.*0123456/s) }) it('skips a release that already exists, so a retry succeeds', async (t) => { @@ -747,6 +767,15 @@ describe('release-merge', () => { assert.match(logged(), /publish is false/) }) + it('refuses to publish without the merged commit, rather than tagging the base branch tip', async (t) => { + const { github, requests } = await octokitFor([]) + const { core } = await coreWithOutputs(t) + + await assert.rejects(runPublish({ github, core, env: publishEnv({ INPUT_BASE_SHA: '' }) }), /not the commit to publish at.*base-sha/s) + await assert.rejects(runPublish({ github, core, env: publishEnv({ INPUT_BASE_SHA: 'main' }) }), /'main' is not the commit/) + assert.deepStrictEqual(requests, []) + }) + 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) From 50b9cf89fd2ed788146f801e565801d48ec7c26b Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 16:25:47 -0300 Subject: [PATCH 14/23] Let develop-branch be empty, for a repository that has no develop branch The merge back into develop was unconditional: the fetch asked for refs/heads/develop and a repository without that branch (the TEC plugins keep main and release branches only) failed before anything was merged. An empty develop-branch now skips the second merge: the release is merged into the base and pushed, nothing is merged back, and the develop-branch output is empty. SOFT-5188 --- .github/actions/release-merge/README.md | 14 ++-- .github/actions/release-merge/action.yml | 6 +- .../actions/release-merge/release-merge.js | 68 +++++++++++-------- templates/workflows/release-merge.yml | 1 + .../release-merge/release-merge.test.js | 29 ++++++++ 5 files changed, 82 insertions(+), 36 deletions(-) diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md index 4ad3037..de66153 100644 --- a/.github/actions/release-merge/README.md +++ b/.github/actions/release-merge/README.md @@ -15,9 +15,10 @@ branch back into develop, then the GitHub release for the version. **The base br version: ${{ inputs.version }} ``` -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. +It is the part of a deploy that is the same for every plugin repository, on a base branch plus +`develop` model or on a base branch alone (`develop-branch: ''` skips the merge back). Building the +plugin, uploading it anywhere and announcing it are the caller's steps, after the action, on 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. @@ -36,7 +37,8 @@ In this order, stopping at the first failure: 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. + divergent copies of the same generated lines. Skipped when `develop-branch` is empty, for a + repository that has no develop branch. 5. Publishes the GitHub release: tag and title are the version, the tag is made at the commit step 3 left the base branch on (the merge commit, so a pull request merged into the base meanwhile is not what gets tagged), the notes are the ones GitHub generates from the pull requests since the @@ -109,7 +111,7 @@ two lines git itself prints, `CONFLICT (…)` and `Automatic merge failed`, are 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. +two different branches (when there is a develop), and the release branch cannot be either of them. ## Inputs @@ -118,7 +120,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: `main`, or `master` where that is the repository's name for it. | -| `develop-branch` | no | `develop` | The branch the base is merged back into. | +| `develop-branch` | no | `develop` | The branch the base is merged back into. Empty for a repository without one: only the first merge is made. | | `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). | diff --git a/.github/actions/release-merge/action.yml b/.github/actions/release-merge/action.yml index 79487d2..7f8d8ff 100644 --- a/.github/actions/release-merge/action.yml +++ b/.github/actions/release-merge/action.yml @@ -30,7 +30,9 @@ inputs: required: false default: 'main' develop-branch: - description: 'The branch the base is merged back into after the release.' + description: >- + The branch the base is merged back into after the release. Pass an empty value for a repository + that has no develop branch: the release is merged into the base and nothing is merged back. required: false default: 'develop' publish: @@ -64,7 +66,7 @@ outputs: value: ${{ steps.merge.outputs.base-sha }} conflict: description: >- - Which branch a merge conflicted on, `main` or `develop` for the defaults, or empty when both + Which branch a merge conflicted on, `main` or `develop` for the defaults, or empty when the 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 }} diff --git a/.github/actions/release-merge/release-merge.js b/.github/actions/release-merge/release-merge.js index 35b70cc..b3753d3 100644 --- a/.github/actions/release-merge/release-merge.js +++ b/.github/actions/release-merge/release-merge.js @@ -69,18 +69,25 @@ function validateVersion(version) { /** * 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`. + * @param {string} branch The input value. + * @param {string} input Which input it is, for the message: `branch`, `base-branch` or + * `develop-branch`. + * @param {object} options + * @param {boolean} options.optional Whether an empty value is allowed, for `develop-branch`: a + * repository without a develop branch leaves it empty. * - * @throws {Error} When it is empty or a full ref. + * @throws {Error} When it is empty and required, or a full ref. * - * @returns {string} The branch name, without surrounding whitespace. + * @returns {string|null} The branch name, without surrounding whitespace; null for an empty + * optional one. */ -function validateBranch(branch, input) { +function validateBranch(branch, input, { optional = false } = {}) { const trimmed = String(branch ?? '').trim() if (trimmed === '') { + if (optional) { + return null + } throw new Error(`No ${input} was given: pass a branch name, such as release/M26.test. Nothing was merged.`) } @@ -388,7 +395,8 @@ async function mergeAndPush({ core, exec, cwd, authEnv, from, into, message, lab * * 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. + * lines. Both merges are `--no-ff`, so each release is one merge commit in the history. A repository + * without a develop branch leaves `develop-branch` empty, and only the first merge is made. * * 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 @@ -410,19 +418,20 @@ 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 develop = validateBranch(env.INPUT_DEVELOP_BRANCH, 'develop-branch', { optional: true }) // 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) { + if (develop !== null && 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 branches = develop === null ? [branch, base] : [branch, base, develop] + for (const name of branches) { const check = await exec.getExecOutput('git', ['check-ref-format', `refs/heads/${name}`], { cwd, ignoreReturnCode: true, @@ -444,7 +453,7 @@ async function runMerge({ github, core, exec, env, cwd = process.cwd() }) { // 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}`) + const refspecs = branches.map((name) => `+refs/heads/${name}:refs/remotes/origin/${name}`) if (tagged) { refspecs.push(`+refs/tags/${version}:refs/tags/${version}`) } @@ -497,31 +506,34 @@ async function runMerge({ github, core, exec, env, cwd = process.cwd() }) { // at when it runs, and it is also the commit the caller builds from. const baseSha = (await gitOrFail(exec, ['rev-parse', 'HEAD'], { cwd, silent: true }, `Reading the merged ${base} commit`)).trim() - 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}`) + let mergedDevelop = null + if (develop === null) { + core.info(`develop-branch is empty: ${base} is not merged back into a develop branch.`) + } else { + await gitOrFail(exec, ['checkout', '--quiet', '-B', develop, `origin/${develop}`], { cwd }, `Checking out ${develop}`) + 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('base-sha', baseSha) - core.setOutput('develop-branch', develop) + 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() + const developPart = develop === null ? 'no develop branch to merge back into' : did(mergedDevelop, base, develop) + await core.summary.addRaw(`Release ${version}: ${did(mergedBase, branch, base)}, ${developPart}.`, true).write() } /** diff --git a/templates/workflows/release-merge.yml b/templates/workflows/release-merge.yml index 6974495..e4591ce 100644 --- a/templates/workflows/release-merge.yml +++ b/templates/workflows/release-merge.yml @@ -66,5 +66,6 @@ jobs: branch: ${{ inputs.branch }} version: ${{ inputs.version }} # base-branch: master + # develop-branch: '' # for a repository without a develop branch # The checkout is now on the merged base branch: build and deploy from here. diff --git a/tests/actions/release-merge/release-merge.test.js b/tests/actions/release-merge/release-merge.test.js index e43b970..8bf048e 100644 --- a/tests/actions/release-merge/release-merge.test.js +++ b/tests/actions/release-merge/release-merge.test.js @@ -268,6 +268,13 @@ describe('release-merge', () => { }) describe('validateBranch', () => { + it('takes an empty value only for an optional input, as develop-branch is', () => { + assert.strictEqual(validateBranch('', 'develop-branch', { optional: true }), null) + assert.strictEqual(validateBranch(' ', 'develop-branch', { optional: true }), null) + assert.strictEqual(validateBranch('develop', 'develop-branch', { optional: true }), 'develop') + assert.throws(() => validateBranch('', 'base-branch'), /No base-branch was given/) + }) + it('accepts a branch name', () => { assert.strictEqual(validateBranch(' release/M26.test ', 'branch'), 'release/M26.test') }) @@ -424,6 +431,28 @@ describe('release-merge', () => { assert.match(summary(), /release\/M26\.test.*main.*develop/s) }) + it('merges only into the base when develop-branch is empty, for a repository without develop', async (t) => { + const bare = remoteWith() + const cwd = checkoutOf(bare) + const develop = git(bare, 'rev-parse', 'develop') + const { github } = await octokitFor(UNTAGGED) + const { core, outputs, summary, logged } = await coreWithOutputs(t) + const { exec, calls } = await recordedRealExec() + + await runMerge({ github, core, exec, env: mergeEnv({ INPUT_DEVELOP_BRANCH: '' }), cwd }) + + assert.strictEqual(fileOn(bare, 'main', 'plugin.php'), 'Version: 4.17.0') + // develop was neither fetched nor touched. + assert.strictEqual(git(bare, 'rev-parse', 'develop'), develop) + assert.ok(!sequence(calls).some((line) => line.includes('refs/heads/develop')), sequence(calls).join('\n')) + assert.strictEqual(calls.filter((call) => call.args[0] === 'push').length, 1) + assert.strictEqual(git(cwd, 'symbolic-ref', '--short', 'HEAD'), 'main') + assert.strictEqual(outputs()['develop-branch'], '') + assert.strictEqual(outputs().conflict, '') + assert.match(logged(), /develop-branch is empty/) + assert.match(summary(), /no develop branch/) + }) + it('leaves the checkout on the merged base, for the steps that build from it', async (t) => { const bare = remoteWith() const cwd = checkoutOf(bare) From 6ade5dc23af5a8b5f69848caa15d348bb58095ef Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 16:25:47 -0300 Subject: [PATCH 15/23] Say what the repository has to allow the token, and that the environment is the gate SOFT-5188 --- .github/actions/release-merge/README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md index de66153..0b71c9a 100644 --- a/.github/actions/release-merge/README.md +++ b/.github/actions/release-merge/README.md @@ -165,6 +165,17 @@ release. It is handed to git through the environment of those commands only (`GI `.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. +The default `github.token` is enough when the repository's rules allow it what the action does: a +direct push to the base branch and to develop (the merges are pushed, not opened as pull requests; +a base branch that only accepts pull requests refuses the push, with any token that is not on the +rule's bypass list), and creating a tag (a tag ruleset should block deletion and updates, not +creation). GiveWP's rules are the reference shape: the branches are protected from deletion and +non-fast-forward pushes, tags have no rule, and `github.token` does everything. The human gate is +not the token: the caller template puts the job in an environment with required reviewers, and the +release branch reached this step after its review and QA approval. A repository whose rules still +need another actor should pass a GitHub App installation token minted in the run rather than a +personal access token, and read the next paragraph. + A release published with the default `github.token` triggers no `release` workflow: GitHub does not start workflows from events a workflow's own token caused. The run that merged the release is the one that deploys it, so a repository that also has an `on: release` deploy does From ff87d5d437e570cd9bcb28bd083e400c36f96fdc Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 16:58:20 -0300 Subject: [PATCH 16/23] Say that an empty develop-branch is written in the caller, not passed through a dispatch input A workflow_dispatch input left empty takes its default, which a test run confirmed, so a repository without develop writes develop-branch: '' in its caller. SOFT-5188 --- .github/actions/release-merge/README.md | 2 +- .github/actions/release-merge/action.yml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md index 0b71c9a..8af7038 100644 --- a/.github/actions/release-merge/README.md +++ b/.github/actions/release-merge/README.md @@ -120,7 +120,7 @@ two different branches (when there is a develop), and the release branch cannot | `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: `main`, or `master` where that is the repository's name for it. | -| `develop-branch` | no | `develop` | The branch the base is merged back into. Empty for a repository without one: only the first merge is made. | +| `develop-branch` | no | `develop` | The branch the base is merged back into. Empty for a repository without one: only the first merge is made. Write `develop-branch: ''` in the caller itself; a `workflow_dispatch` input left empty takes its default, so the empty value cannot be passed through one. | | `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). | diff --git a/.github/actions/release-merge/action.yml b/.github/actions/release-merge/action.yml index 7f8d8ff..958e30d 100644 --- a/.github/actions/release-merge/action.yml +++ b/.github/actions/release-merge/action.yml @@ -32,7 +32,8 @@ inputs: develop-branch: description: >- The branch the base is merged back into after the release. Pass an empty value for a repository - that has no develop branch: the release is merged into the base and nothing is merged back. + that has no develop branch: the release is merged into the base and nothing is merged back. Write + it in the caller (`develop-branch: ''`); a workflow_dispatch input left empty takes its default. required: false default: 'develop' publish: From 073aae1605181b133503c1f45e93e4f0131ffbf0 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 17:09:45 -0300 Subject: [PATCH 17/23] Leave publishing the release to the caller: release-merge only merges The publish step, the publish input and the published / release-url outputs are gone. The action merges, pushes and leaves the commit to publish at in base-sha; the caller builds from it and publishes the GitHub release after the build, so the release can carry the build and is published only once it exists. validateVersion moves to the shared lib, for the publish action that follows. SOFT-5188 --- .github/actions/release-merge/README.md | 76 ++++---- .github/actions/release-merge/action.yml | 58 ++---- .../actions/release-merge/release-merge.js | 181 ++---------------- .github/lib/versions.js | 37 +++- README.md | 2 +- templates/workflows/release-merge.yml | 15 +- .../release-merge/release-merge.test.js | 140 +------------- 7 files changed, 111 insertions(+), 398 deletions(-) diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md index 8af7038..d97a4be 100644 --- a/.github/actions/release-merge/README.md +++ b/.github/actions/release-merge/README.md @@ -1,8 +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. **The base branch is `main`, or -`master` where that is the repository's name for it**, set with `base-branch`: +Merges a release: the release branch into the base branch, then the base branch back into develop. +**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 @@ -10,11 +10,18 @@ branch back into develop, then the GitHub release for the version. **The base br persist-credentials: false - uses: stellarwp/plugin-toolbox/.github/actions/release-merge@v1 + id: merge with: branch: ${{ inputs.branch }} version: ${{ inputs.version }} + +# Build the plugin here, then publish the release at ${{ steps.merge.outputs.base-sha }}. ``` +Publishing the GitHub release is not this action's: the caller does it after its build, at the +commit this action leaves on the base branch (`base-sha`), so the release can carry the build and is +published only once it exists. + It is the part of a deploy that is the same for every plugin repository, on a base branch plus `develop` model or on a base branch alone (`develop-branch: ''` skips the merge back). Building the plugin, uploading it anywhere and announcing it are the caller's steps, after the action, on the @@ -39,13 +46,9 @@ In this order, stopping at the first failure: descends from the base and consecutive releases bring one version bump each instead of divergent copies of the same generated lines. Skipped when `develop-branch` is empty, for a repository that has no develop branch. -5. Publishes the GitHub release: tag and title are the version, the tag is made at the commit step - 3 left the base branch on (the merge commit, so a pull request merged into the base meanwhile is - not what gets tagged), 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 +The checkout is left on the base branch at the merged commit, clean, and that commit is the +`base-sha` output: what the caller builds from and tags. The merge commits are +made by `github-actions[bot]`; the pushes are made by the identity of `token`, also `github-actions[bot]` for the default token. ## Running it again @@ -54,14 +57,14 @@ Every step is safe to run again after a late failure, which is what a retry from 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 - 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 version already published counts as a retry only when its tag contains the release branch, + that is, when it was published from this branch. See [Versions it refuses](#versions-it-refuses). + The caller's publish then has nothing to do. - 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. +So a run that failed after merging the base, or in a later step of the caller, is rerun as it is; +nothing has to be undone first. ## When a merge conflicts @@ -121,8 +124,7 @@ two different branches (when there is a develop), and the release branch cannot | `version` | yes | | The version being released: three or four numeric parts. | | `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. Empty for a repository without one: only the first merge is made. Write `develop-branch: ''` in the caller itself; a `workflow_dispatch` input left empty takes its default, so the empty value cannot be passed through one. | -| `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). | +| `token` | no | `github.token` | The token for the fetch, the pushes and the tag reads. Needs `contents: write`. See [The token](#the-token). | ## Outputs @@ -130,13 +132,8 @@ two different branches (when there is a develop), and the release branch cannot |---|---| | `version` | The version released. | | `branch` | The release branch merged. | -| `base-sha` | The commit on the base branch that holds the release: the merge commit, or the base's tip on a retry that found the merge done. The release is published at it, and the checkout is left on it. | +| `base-sha` | The commit on the base branch that holds the release: the merge commit, or the base's tip on a retry that found the merge done. The commit to publish the release at; the checkout is left on it. | | `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 merge step also sets `base-branch` and `develop-branch` as step outputs, for the publish step; -the action does not expose them. `base-sha` is set the same way and is also an output of the action. ## One deploy at a time @@ -159,27 +156,22 @@ 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 tag +and release reads. 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. The default `github.token` is enough when the repository's rules allow it what the action does: a -direct push to the base branch and to develop (the merges are pushed, not opened as pull requests; -a base branch that only accepts pull requests refuses the push, with any token that is not on the -rule's bypass list), and creating a tag (a tag ruleset should block deletion and updates, not -creation). GiveWP's rules are the reference shape: the branches are protected from deletion and -non-fast-forward pushes, tags have no rule, and `github.token` does everything. The human gate is -not the token: the caller template puts the job in an environment with required reviewers, and the +direct push to the base branch and to develop. The merges are pushed, not opened as pull requests, +so a base branch that only accepts pull requests refuses the push, with any token that is not on +the rule's bypass list. GiveWP's rules are the reference shape: the branches are protected from +deletion and non-fast-forward pushes, and `github.token` pushes the merges. The human gate is not +the token: the caller template puts the job in an environment with required reviewers, and the release branch reached this step after its review and QA approval. A repository whose rules still need another actor should pass a GitHub App installation token minted in the run rather than a -personal access token, and read the next paragraph. - -A release published with the default `github.token` triggers no `release` workflow: GitHub does not -start workflows from events a workflow's own token caused. The run that merged 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. +personal access token. A release published with the default `github.token` triggers no `release` +workflow; one published with a personal access token or a GitHub App token does. ## The checkout @@ -192,11 +184,11 @@ 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. +One [actions/github-script](https://github.com/actions/github-script) step runs +[`release-merge.js`](./release-merge.js): the checks and the merges. 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 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/.github/actions/release-merge/action.yml b/.github/actions/release-merge/action.yml index 958e30d..f1af470 100644 --- a/.github/actions/release-merge/action.yml +++ b/.github/actions/release-merge/action.yml @@ -1,13 +1,13 @@ # 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), then the base branch back into develop. The part of a deploy that is the same for every +# plugin; what happens after the merge (building, publishing the GitHub release, wordpress.org, +# announcements) is the caller's, at the commit this one leaves on the base (`base-sha`). # -# 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. +# The checks and the merges live in release-merge.js, run by actions/github-script, so there is no +# dependency to install and nothing to build. The merges are git in the caller's checkout; the tag +# and release reads 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 +description: Merges the release branch into the base and the base back into develop inputs: branch: @@ -23,7 +23,7 @@ inputs: paths.versions is read, and a different version in any of them is refused. A pre-release such as 4.17.0-beta.1 and an empty value are refused before anything is merged, and so is a version whose tag already exists, unless that tag's release was published from this branch, which makes - the run a retry. + the run a retry. Publishing the release is the caller's, after this action. required: true base-branch: description: 'The branch the release is merged into: `main`, or `master` where that is the name.' @@ -36,19 +36,12 @@ inputs: it in the caller (`develop-branch: ''`); a workflow_dispatch input left empty takes its default. 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: the run that merged the release is the one that deploys it. + Token the fetch and the pushes are made with, and the tag and release reads. 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`. required: false default: ${{ github.token }} @@ -62,7 +55,7 @@ outputs: base-sha: description: >- The commit on the base branch that holds the release: the merge commit, or the base's tip on a - retry that found the merge already done. The release is published at this commit, and it is the + retry that found the merge already done. The commit to publish the release at; it is also the commit the checkout is left on. value: ${{ steps.merge.outputs.base-sha }} conflict: @@ -71,12 +64,6 @@ outputs: 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' @@ -95,8 +82,6 @@ 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. @@ -105,20 +90,3 @@ runs: 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. base-branch and develop-branch - # are outputs of that step for this hand-over only; the action exposes neither. base-sha is - # the commit the release is published at. - INPUT_VERSION: ${{ steps.merge.outputs.version }} - INPUT_BASE_BRANCH: ${{ steps.merge.outputs.base-branch }} - INPUT_BASE_SHA: ${{ steps.merge.outputs.base-sha }} - 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 index b3753d3..da6dd20 100644 --- a/.github/actions/release-merge/release-merge.js +++ b/.github/actions/release-merge/release-merge.js @@ -2,20 +2,19 @@ /** * 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. + * name), then the base branch back into develop. Publishing the GitHub release is the caller's, + * after this action and its build, at the commit this one leaves on the base (`base-sha`). * - * 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. + * 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. The functions it is 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. + * merges as a no-op. */ -const { STELLARWP_VERSION_PATTERN } = require('../../lib/versions.js') +const { validateVersion: validateReleaseVersion } = 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') @@ -33,37 +32,16 @@ const BOT_IDENTITY = { 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: 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. + * Reads the version to release: the shared rule, with this action's closing line. * * @param {string} version The `version` input. * - * @throws {Error} When it is empty or not of that shape. + * @throws {Error} When it is empty or not three or four numeric parts. * * @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 + return validateReleaseVersion(version, 'Nothing was merged.') } /** @@ -101,51 +79,6 @@ function validateBranch(branch, input, { optional = false } = {}) { return trimmed } -/** - * Reads the commit the release is published at, the merge step's `base-sha` output. - * - * @param {string} sha The output value. - * - * @throws {Error} When it is not a full commit hash. - * - * @returns {string} The hash. - */ -function validateSha(sha) { - const trimmed = String(sha ?? '').trim() - - if (!/^[0-9a-f]{40}$/.test(trimmed)) { - throw new Error( - `'${trimmed}' is not the commit to publish at: expected the merge step's base-sha output, a full ` + - 'commit hash. Nothing was published.' - ) - } - - 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. @@ -210,7 +143,7 @@ function isConflict(result) { * 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 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 + * reuse whatever commit the tag points at, so it is refused here, with the base and develop * untouched. * * @param {object} options @@ -419,8 +352,6 @@ 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', { optional: true }) - // 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 (develop !== null && base === develop) { @@ -486,7 +417,7 @@ async function runMerge({ github, core, exec, env, cwd = process.cwd() }) { 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.`) + core.info(`${version} is already published (${releaseUrl}) from ${branch}: nothing to publish again.`) } await gitOrFail(exec, ['checkout', '--quiet', '-B', base, `origin/${base}`], { cwd }, `Checking out ${base}`) @@ -502,8 +433,9 @@ async function runMerge({ github, core, exec, env, cwd = process.cwd() }) { }) // The commit the release is published at: the merge commit, or the base's tip on a retry that - // found the merge done. The publish step tags this commit, not whatever the base branch points - // at when it runs, and it is also the commit the caller builds from. + // found the merge done. The caller publishes the release at it, so the tag is made at this commit + // and not at whatever the base branch points to by then; it is also the commit the caller builds + // from. const baseSha = (await gitOrFail(exec, ['rev-parse', 'HEAD'], { cwd, silent: true }, `Reading the merged ${base} commit`)).trim() let mergedDevelop = null @@ -536,92 +468,11 @@ async function runMerge({ github, core, exec, env, cwd = process.cwd() }) { await core.summary.addRaw(`Release ${version}: ${did(mergedBase, branch, base)}, ${developPart}.`, true).write() } -/** - * Publishes the GitHub release for the version, unless `publish` is false or it already exists. - * - * 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. - * @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, the merged commit is missing, 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') - const sha = validateSha(env.INPUT_BASE_SHA) - - 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 - } - - // The tag is made at the commit the merge step left the base on, so it points at the release - // even if the base branch moved while this step ran. - const { data: notes } = await github.rest.repos.generateReleaseNotes({ - owner, - repo, - tag_name: version, - target_commitish: sha, - }) - const { data: release } = await github.rest.repos.createRelease({ - owner, - repo, - tag_name: version, - target_commitish: sha, - name: version, - body: notes.body, - }) - - core.info(`Published ${version} at ${sha.slice(0, 7)}: ${release.html_url}`) - core.setOutput('published', 'true') - core.setOutput('release-url', release.html_url) - await core.summary - .addRaw(`Published release ${version} on \`${base}\` at \`${sha.slice(0, 7)}\`: ${release.html_url}`, true) - .write() -} - module.exports = { runMerge, - runPublish, runPreflight, validateVersion, validateBranch, - validateSha, - readPublish, authorizationHeader, gitAuthEnv, mergeMessage, diff --git a/.github/lib/versions.js b/.github/lib/versions.js index 2c350fe..5780ee9 100644 --- a/.github/lib/versions.js +++ b/.github/lib/versions.js @@ -62,4 +62,39 @@ function compareVersions(a, b) { return 0 } -module.exports = { toParts, compareVersions, STELLARWP_VERSION_PATTERN } +/** + * Reads a version to release, refusing anything but three or four numeric parts. + * + * 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. + * @param {string} what What was not done, to close the message, e.g. `Nothing was merged.` + * + * @throws {Error} When it is empty or not of that shape. + * + * @returns {string} The version, without surrounding whitespace. + */ +function validateVersion(version, what) { + 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. ${what}` + ) + } + + 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. ${what}` + ) + } + + return trimmed +} + +module.exports = { toParts, compareVersions, validateVersion, STELLARWP_VERSION_PATTERN } diff --git a/README.md b/README.md index a20099a..0a9cd4d 100644 --- a/README.md +++ b/README.md @@ -160,7 +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`. +- [x] Add `release-merge`: the release branch into the base branch, the base back into develop, each step safe to rerun and a conflict reported with a `MERGE_CONFLICT` line. A caller to copy is in `templates/workflows/release-merge.yml`. - [ ] 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 index e4591ce..32b9fe1 100644 --- a/templates/workflows/release-merge.yml +++ b/templates/workflows/release-merge.yml @@ -1,9 +1,9 @@ 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. +# Merges a release: the release branch into the base branch (`main` here, `master` in some +# repositories), then the base branch back into develop. Nothing is built, published or deployed +# here; a plugin repository adds its own steps after the action, on the checkout it leaves on the +# merged base branch, and publishes the GitHub release at `steps.merge.outputs.base-sha`. # # 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`. @@ -52,7 +52,7 @@ jobs: # dispatches on. environment: live permissions: - contents: write # the action pushes the merges and publishes the release + contents: write # the action pushes the merges; the caller's publish needs it too steps: - uses: actions/checkout@v6 with: @@ -61,11 +61,12 @@ jobs: persist-credentials: false - uses: stellarwp/plugin-toolbox/.github/actions/release-merge@v1 - id: release + id: merge with: branch: ${{ inputs.branch }} version: ${{ inputs.version }} # base-branch: master # develop-branch: '' # for a repository without a develop branch - # The checkout is now on the merged base branch: build and deploy from here. + # The checkout is now on the merged base branch: build, publish the GitHub release at + # ${{ steps.merge.outputs.base-sha }}, and deploy from here. diff --git a/tests/actions/release-merge/release-merge.test.js b/tests/actions/release-merge/release-merge.test.js index 8bf048e..7193faf 100644 --- a/tests/actions/release-merge/release-merge.test.js +++ b/tests/actions/release-merge/release-merge.test.js @@ -9,12 +9,9 @@ const { execFileSync } = require('node:child_process') const { runMerge, - runPublish, runPreflight, validateVersion, validateBranch, - validateSha, - readPublish, authorizationHeader, gitAuthEnv, mergeMessage, @@ -172,27 +169,6 @@ function mergeEnv(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_BASE_SHA: MERGED_SHA, - INPUT_PUBLISH: 'true', - GITHUB_REPOSITORY: 'stellarwp/plugin', - ...overrides, - } -} - -/** The merge commit the merge step hands the publish step, as `base-sha`. */ -const MERGED_SHA = '0123456789abcdef0123456789abcdef01234567' - /** The .puprc of the fixture: the plugin header and the readme's Stable tag hold the version. */ const PUPRC = { paths: { @@ -207,8 +183,6 @@ const PUPRC = { 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' @@ -288,31 +262,6 @@ describe('release-merge', () => { }) }) - describe('validateSha', () => { - it('takes a full commit hash and nothing else', () => { - assert.strictEqual(validateSha(` ${MERGED_SHA} `), MERGED_SHA) - for (const value of ['', 'main', MERGED_SHA.slice(0, 7), MERGED_SHA.toUpperCase()]) { - assert.throws(() => validateSha(value), /not the commit to publish at/) - } - }) - }) - - 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') @@ -423,7 +372,7 @@ describe('release-merge', () => { branch: 'release/M26.test', version: '4.17.0', 'base-branch': 'main', - // The merge commit on the remote: what the publish step tags and the caller builds from. + // The merge commit on the remote: what the caller builds from and tags. 'base-sha': git(bare, 'rev-parse', 'main'), 'develop-branch': 'develop', conflict: '', @@ -516,7 +465,7 @@ describe('release-merge', () => { assert.strictEqual(retry.outputs()['base-sha'], main) }) - it('says so when the release is already published from this branch, 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 has nothing to do', 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') @@ -527,7 +476,7 @@ 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.match(logged(), /4\.17\.0 is already published.*nothing to publish again/s) assert.strictEqual(fileOn(bare, 'develop', 'plugin.php'), 'Version: 4.17.0') }) @@ -656,19 +605,6 @@ describe('release-merge', () => { 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. @@ -750,74 +686,4 @@ describe('release-merge', () => { }) }) - describe('runPublish', () => { - it('publishes the release at the merged commit, 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}`]) - // The merge step's commit, not the base branch's name: a commit pushed to main between the - // merge and this step is not what gets tagged. - assert.deepStrictEqual(requests[1].body, { tag_name: '4.17.0', target_commitish: MERGED_SHA }) - assert.deepStrictEqual(requests[2].body, { - tag_name: '4.17.0', - target_commitish: MERGED_SHA, - 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.*0123456/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('refuses to publish without the merged commit, rather than tagging the base branch tip', async (t) => { - const { github, requests } = await octokitFor([]) - const { core } = await coreWithOutputs(t) - - await assert.rejects(runPublish({ github, core, env: publishEnv({ INPUT_BASE_SHA: '' }) }), /not the commit to publish at.*base-sha/s) - await assert.rejects(runPublish({ github, core, env: publishEnv({ INPUT_BASE_SHA: 'main' }) }), /'main' is not the commit/) - assert.deepStrictEqual(requests, []) - }) - - 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 0891a30f7586f383b2265eaf7c9cc048a13a52fc Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 17:45:52 -0300 Subject: [PATCH 18/23] Fix the sentence on what the rules have to allow the token SOFT-5188 --- .github/actions/release-merge/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md index d97a4be..bf6af45 100644 --- a/.github/actions/release-merge/README.md +++ b/.github/actions/release-merge/README.md @@ -162,7 +162,7 @@ and release reads. It is handed to git through the environment of those commands `.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. -The default `github.token` is enough when the repository's rules allow it what the action does: a +The default `github.token` is enough when the repository's rules allow what the action does: a direct push to the base branch and to develop. The merges are pushed, not opened as pull requests, so a base branch that only accepts pull requests refuses the push, with any token that is not on the rule's bypass list. GiveWP's rules are the reference shape: the branches are protected from From b11e46a825033ac50bf9af2a3163279b4d26f6fa Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 17:53:49 -0300 Subject: [PATCH 19/23] Keep every waiting deploy in the concurrency queue The default queue keeps one pending run and cancels it when another arrives, so a third release requested while one waited for approval dropped the second. queue: max keeps them all, in order. SOFT-5188 --- .github/actions/release-merge/README.md | 5 +++-- templates/workflows/release-merge.yml | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md index bf6af45..6d90168 100644 --- a/.github/actions/release-merge/README.md +++ b/.github/actions/release-merge/README.md @@ -147,12 +147,13 @@ deploy of the repository, as the template sets: concurrency: group: ${{ github.workflow }} cancel-in-progress: false + queue: max ``` 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. +group. `queue: max` keeps every waiting run, in the order they arrived; without it GitHub keeps one +run waiting per group and cancels it when another arrives. ## The token diff --git a/templates/workflows/release-merge.yml b/templates/workflows/release-merge.yml index 32b9fe1..644b6cd 100644 --- a/templates/workflows/release-merge.yml +++ b/templates/workflows/release-merge.yml @@ -36,10 +36,12 @@ 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` workflow, for instance) gives both the same group. +# (an `on: release` workflow, for instance) gives both the same group. `queue: max` keeps every +# waiting run; the default keeps one and cancels it when another arrives. concurrency: group: ${{ github.workflow }} cancel-in-progress: false + queue: max permissions: contents: read From f45a2cf69772369a0f24c2e645186fb70a76e163 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 18:14:03 -0300 Subject: [PATCH 20/23] Drop the production input from the merge template: the dashboard no longer sends it SOFT-5188 --- templates/workflows/release-merge.yml | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/templates/workflows/release-merge.yml b/templates/workflows/release-merge.yml index 644b6cd..987c20c 100644 --- a/templates/workflows/release-merge.yml +++ b/templates/workflows/release-merge.yml @@ -8,9 +8,8 @@ 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: `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. +# The inputs are the interface a release tool dispatches: `branch` and `version`, what the release +# dashboard sends for the deploy step. # # 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 branch=release/M26.jigglypuff -f version=4.17.0`. @@ -26,11 +25,6 @@ on: 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' # 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 From f81f0cf0676c38fa353af9af78f85e4a79fdeead Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 18:37:43 -0300 Subject: [PATCH 21/23] Back to the default concurrency queue: actionlint does not know queue: max The plugin repositories lint their workflows with actionlint 1.7, which refuses the key, and the template exists to be copied into them. The README says what the default does and that a cancelled deploy is started again. SOFT-5188 --- .github/actions/release-merge/README.md | 6 +++--- templates/workflows/release-merge.yml | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md index 6d90168..4e8f0b5 100644 --- a/.github/actions/release-merge/README.md +++ b/.github/actions/release-merge/README.md @@ -147,13 +147,13 @@ deploy of the repository, as the template sets: concurrency: group: ${{ github.workflow }} cancel-in-progress: false - queue: max ``` 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. `queue: max` keeps every waiting run, in the order they arrived; without it GitHub keeps one -run waiting per group and cancels it when another arrives. +group. GitHub keeps one run waiting per group: a third deploy started while one is already waiting +cancels the waiting one, which is started again. (`queue: max` would keep every waiting run, but +actionlint 1.7 does not know the key and the plugin repositories lint their workflows with it.) ## The token diff --git a/templates/workflows/release-merge.yml b/templates/workflows/release-merge.yml index 987c20c..8da108a 100644 --- a/templates/workflows/release-merge.yml +++ b/templates/workflows/release-merge.yml @@ -30,12 +30,11 @@ 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` workflow, for instance) gives both the same group. `queue: max` keeps every -# waiting run; the default keeps one and cancels it when another arrives. +# (an `on: release` workflow, for instance) gives both the same group. GitHub keeps one run +# waiting per group and cancels it when another arrives; a cancelled deploy is started again. concurrency: group: ${{ github.workflow }} cancel-in-progress: false - queue: max permissions: contents: read From f145579ce4409f34101837d3feb8cbcc10fc8bc8 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 2 Nov 2026 18:47:18 -0300 Subject: [PATCH 22/23] Drop the merge-only caller template: a deploy workflow merges and publishes No repository merges a release without publishing it, so a template around release-merge alone had no consumer. The complete deploy workflow, release-merge then the build then release-publish, comes with the publish action. SOFT-5188 --- .github/actions/release-merge/README.md | 6 +-- README.md | 2 +- templates/workflows/release-merge.yml | 67 ------------------------- 3 files changed, 3 insertions(+), 72 deletions(-) delete mode 100644 templates/workflows/release-merge.yml diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md index 4e8f0b5..2235354 100644 --- a/.github/actions/release-merge/README.md +++ b/.github/actions/release-merge/README.md @@ -26,8 +26,6 @@ It is the part of a deploy that is the same for every plugin repository, on a ba `develop` model or on a base branch alone (`develop-branch: ''` skips the merge back). Building the plugin, uploading it anywhere and announcing it are the caller's steps, after the action, on 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 @@ -141,7 +139,7 @@ Two releases prepared with the same version, deployed at the same time, would me 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: +deploy of the repository: ```yaml concurrency: @@ -168,7 +166,7 @@ direct push to the base branch and to develop. The merges are pushed, not opened so a base branch that only accepts pull requests refuses the push, with any token that is not on the rule's bypass list. GiveWP's rules are the reference shape: the branches are protected from deletion and non-fast-forward pushes, and `github.token` pushes the merges. The human gate is not -the token: the caller template puts the job in an environment with required reviewers, and the +the token: the caller puts the job in an environment with required reviewers, and the release branch reached this step after its review and QA approval. A repository whose rules still need another actor should pass a GitHub App installation token minted in the run rather than a personal access token. A release published with the default `github.token` triggers no `release` diff --git a/README.md b/README.md index 0a9cd4d..551f89e 100644 --- a/README.md +++ b/README.md @@ -160,7 +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, each step safe to rerun and a conflict reported with a `MERGE_CONFLICT` line. A caller to copy is in `templates/workflows/release-merge.yml`. +- [x] Add `release-merge`: the release branch into the base branch, the base back into develop, each step safe to rerun and a conflict reported with a `MERGE_CONFLICT` line. - [ ] 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 deleted file mode 100644 index 8da108a..0000000 --- a/templates/workflows/release-merge.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Release Merge - -# Merges a release: the release branch into the base branch (`main` here, `master` in some -# repositories), then the base branch back into develop. Nothing is built, published or deployed -# here; a plugin repository adds its own steps after the action, on the checkout it leaves on the -# merged base branch, and publishes the GitHub release at `steps.merge.outputs.base-sha`. -# -# 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: `branch` and `version`, what the release -# dashboard sends for the deploy step. -# -# 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 branch=release/M26.jigglypuff -f version=4.17.0`. - -on: - workflow_dispatch: - inputs: - branch: - description: 'Release branch to merge, e.g. release/M26.jigglypuff' - required: true - type: string - version: - description: 'Version being released, e.g. 4.17.0' - required: true - type: string - -# One deploy at a time for the whole repository, not one per release. Two releases prepared with the -# same version would otherwise merge side by side, and the second would land its changes under a -# version the first has just published. Run one after the other, the second finds the tag published -# and the action refuses it before merging. A repository that also deploys from another workflow -# (an `on: release` workflow, for instance) gives both the same group. GitHub keeps one run -# waiting per group and cancels it when another arrives; a cancelled deploy is started again. -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -permissions: - contents: read - -jobs: - 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; the caller's publish needs it too - 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: merge - with: - branch: ${{ inputs.branch }} - version: ${{ inputs.version }} - # base-branch: master - # develop-branch: '' # for a repository without a develop branch - - # The checkout is now on the merged base branch: build, publish the GitHub release at - # ${{ steps.merge.outputs.base-sha }}, and deploy from here. From 6f5c7d7eb7c66e70065579cc5ee8fa7c9915c54e Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Tue, 3 Nov 2026 15:25:21 -0300 Subject: [PATCH 23/23] Merge through the GitHub API instead of git in the caller's checkout The merges, the branch and version-file reads and the ancestry check go through Octokit: repos.merge, repos.getBranch, repos.getContent and repos.compareCommitsWithBasehead. GitHub makes the merge commits under the token's identity and signs them, so the bot identity, the token handed to git through its environment and the shallow-clone handling are gone, and the action needs no checkout. The caller checks out base-sha afterwards to build from it. A 204 from the merge endpoint is the retry case. A 409 that says "Merge conflict" prints the MERGE_CONFLICT marker as before; a 409 for a base that moved while the merge was being made fails without it, to be run again. readTag in lib/github.js reads the commit a tag points at, through the tag object when the tag is annotated, for the ancestry check against the release branch; tagExists keeps its contract on top of it. The tests run the action against a recorded client and assert the requests it sends and the ones it does not, with no git on disk. The recorded fetch answers a 204 without a body, as the merge endpoint does. Answers the review on #10: the raw git commands, the 41898282+ prefix (the constant is gone) and the dashboard named in the regex comment (the regex is gone; the marker line is the one place left that names it). --- .github/actions/release-merge/README.md | 124 ++-- .github/actions/release-merge/action.yml | 25 +- .../actions/release-merge/release-merge.js | 397 +++++----- .github/lib/github.js | 42 +- .../release-merge/release-merge.test.js | 691 +++++++----------- tests/support/actions.js | 6 + 6 files changed, 575 insertions(+), 710 deletions(-) diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md index 2235354..0b9a211 100644 --- a/.github/actions/release-merge/README.md +++ b/.github/actions/release-merge/README.md @@ -1,20 +1,21 @@ # release-merge -Merges a release: the release branch into the base branch, then the base branch back into develop. -**The base branch is `main`, or `master` where that is the repository's name for it**, set with -`base-branch`: +Merges a release: the release branch into the base branch, then the base branch back into develop, +through the GitHub API. **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 - with: - persist-credentials: false - - uses: stellarwp/plugin-toolbox/.github/actions/release-merge@v1 id: merge with: branch: ${{ inputs.branch }} version: ${{ inputs.version }} +# The merged commit, to build the release from: +- uses: actions/checkout@v6 + with: + ref: ${{ steps.merge.outputs.base-sha }} + # Build the plugin here, then publish the release at ${{ steps.merge.outputs.base-sha }}. ``` @@ -24,8 +25,8 @@ published only once it exists. It is the part of a deploy that is the same for every plugin repository, on a base branch plus `develop` model or on a base branch alone (`develop-branch: ''` skips the merge back). Building the -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. +plugin, uploading it anywhere and announcing it are the caller's steps, after the action, on a +checkout of `base-sha`. ## What it does @@ -33,28 +34,33 @@ 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. 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. +2. Checks that the release branch, the base branch and develop exist, then 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 - (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. Skipped when `develop-branch` is empty, for a - repository that has no develop branch. -The checkout is left on the base branch at the merged commit, clean, and that commit is the -`base-sha` output: what the caller builds from and tags. The merge commits are -made by `github-actions[bot]`; the pushes are made by the identity of `token`, also -`github-actions[bot]` for the default token. + (or `into master`). +4. Merges the **merged commit** into develop as a merge commit, `Merge main back into develop + (release/…)`. 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. Skipped when `develop-branch` is empty, for a repository that has no + develop branch. + +The merge commit on the base branch is the `base-sha` output: what the caller checks out, builds +from and tags. GitHub makes both merge commits under the identity of `token`, `github-actions[bot]` +for the default token, and signs them. The API never fast-forwards, so each release is one merge +commit in the history. + +The action reads and writes no checkout: it needs none before it, and leaves whatever checkout the +job has as it was. Check out `base-sha` after it for the steps that build the release. ## Running it again 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 branch that already holds the other merges as nothing to merge: GitHub answers that the base + already contains the head, and nothing is written. - A version already published counts as a retry only when its tag contains the release branch, that is, when it was published from this branch. See [Versions it refuses](#versions-it-refuses). The caller's publish then has nothing to do. @@ -66,24 +72,26 @@ 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. +GitHub refuses the merge, nothing is written for that branch, and the step fails with the branch +named. The branches already merged stay merged: 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 +API does not say which files conflict; the merge done by hand shows them. 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. +then run this step again. Nothing was merged into 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. +workflow, so it is not in the step's echoed inputs and only appears when a merge did conflict. + +A merge GitHub refuses for another reason (the base branch moved while the merge was being made) +fails the step without the marker; running the step again is enough. ## Versions it refuses @@ -111,18 +119,18 @@ two lines git itself prints, `CONFLICT (…)` and `Automatic merge failed`, are 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 +The branches are checked too: each has to exist in the repository, the base and develop have to be two different branches (when there is a develop), 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`. | +| `branch` | yes | | The release branch to merge, e.g. `release/M26.jigglypuff`. A branch name. | | `version` | yes | | The version being released: three or four numeric parts. | | `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. Empty for a repository without one: only the first merge is made. Write `develop-branch: ''` in the caller itself; a `workflow_dispatch` input left empty takes its default, so the empty value cannot be passed through one. | -| `token` | no | `github.token` | The token for the fetch, the pushes and the tag reads. Needs `contents: write`. See [The token](#the-token). | +| `token` | no | `github.token` | The token for the merges and the reads. Needs `contents: write`. See [The token](#the-token). | ## Outputs @@ -130,7 +138,7 @@ two different branches (when there is a develop), and the release branch cannot |---|---| | `version` | The version released. | | `branch` | The release branch merged. | -| `base-sha` | The commit on the base branch that holds the release: the merge commit, or the base's tip on a retry that found the merge done. The commit to publish the release at; the checkout is left on it. | +| `base-sha` | The commit on the base branch that holds the release: the merge commit, or the base's tip on a retry that found the merge done. The commit to check out, build from and publish the release at. | | `conflict` | The branch a merge conflicted on, or empty. A conflict fails the step, so read it with `continue-on-error` or in a later `if: failure()` step. | ## One deploy at a time @@ -155,39 +163,29 @@ actionlint 1.7 does not know the key and the plugin repositories lint their work ## The token -The job needs `contents: write`, and the token is used for the fetch, the two pushes and the tag -and release reads. 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. +The job needs `contents: write`, and the token is used for the two merges and for the branch, tag, +release and file reads. It only ever reaches github-script's client: it is never handed to git, +never on a command line and never in the log. The caller's checkout, when there is one, can use +`persist-credentials: false`. The default `github.token` is enough when the repository's rules allow what the action does: a -direct push to the base branch and to develop. The merges are pushed, not opened as pull requests, -so a base branch that only accepts pull requests refuses the push, with any token that is not on -the rule's bypass list. GiveWP's rules are the reference shape: the branches are protected from -deletion and non-fast-forward pushes, and `github.token` pushes the merges. The human gate is not -the token: the caller puts the job in an environment with required reviewers, and the -release branch reached this step after its review and QA approval. A repository whose rules still -need another actor should pass a GitHub App installation token minted in the run rather than a -personal access token. A release published with the default `github.token` triggers no `release` +merge written straight to the base branch and to develop. The merges are made through the API, not +opened as pull requests, so a base branch that only accepts pull requests refuses the merge, with +any token that is not on the rule's bypass list; a rule that requires signed commits is satisfied, +since GitHub signs the merge commits it makes. GiveWP's rules are the reference shape: the branches +are protected from deletion and non-fast-forward pushes, and `github.token` writes the merges. The +human gate is not the token: the caller puts the job in an environment with required reviewers, and +the release branch reached this step after its review and QA approval. A repository whose rules +still need another actor should pass a GitHub App installation token minted in the run rather than +a personal access token. A release published with the default `github.token` triggers no `release` workflow; one published with a personal access token or a GitHub App token does. -## 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 One [actions/github-script](https://github.com/actions/github-script) step runs -[`release-merge.js`](./release-merge.js): the checks and the merges. 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 go -through github-script's Octokit. +[`release-merge.js`](./release-merge.js): the checks and the merges, all through github-script's +Octokit. 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 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. +The tests, `node --test` from the repository root, run the action against a recorded client and +assert the requests it sends and the ones it does not; no git and no network. diff --git a/.github/actions/release-merge/action.yml b/.github/actions/release-merge/action.yml index f1af470..6faa341 100644 --- a/.github/actions/release-merge/action.yml +++ b/.github/actions/release-merge/action.yml @@ -4,16 +4,16 @@ # announcements) is the caller's, at the commit this one leaves on the base (`base-sha`). # # The checks and the merges live in release-merge.js, run by actions/github-script, so there is no -# dependency to install and nothing to build. The merges are git in the caller's checkout; the tag -# and release reads go through the GitHub API. +# dependency to install and nothing to build. Everything goes through the GitHub API: the branch, +# tag and release reads, the version-file reads and the merges themselves. No checkout is needed; +# the caller checks out `base-sha` afterwards to build from it. name: Release merge description: Merges the release branch into the base and the base back into develop 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. + The release branch to merge, e.g. `release/M26.jigglypuff`. A branch name, not any git ref. required: true version: description: >- @@ -38,10 +38,10 @@ inputs: default: 'develop' token: description: >- - Token the fetch and the pushes are made with, and the tag and release reads. 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`. + Token the merges are made with, and the branch, tag, release and file reads. It needs + `contents: write` on the repository, so the job needs that permission too. GitHub makes the + merge commits under its identity and signs them. It only ever reaches github-script's client, + never git or the log. required: false default: ${{ github.token }} @@ -55,8 +55,8 @@ outputs: base-sha: description: >- The commit on the base branch that holds the release: the merge commit, or the base's tip on a - retry that found the merge already done. The commit to publish the release at; it is also the - commit the checkout is left on. + retry that found the merge already done. The commit to check out, build from and publish the + release at. value: ${{ steps.merge.outputs.base-sha }} conflict: description: >- @@ -82,11 +82,10 @@ runs: 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. + # Every read and the merges 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 }) + await runMerge({ github, core, env: process.env }) diff --git a/.github/actions/release-merge/release-merge.js b/.github/actions/release-merge/release-merge.js index da6dd20..e96ffc1 100644 --- a/.github/actions/release-merge/release-merge.js +++ b/.github/actions/release-merge/release-merge.js @@ -5,32 +5,18 @@ * name), then the base branch back into develop. Publishing the GitHub release is the caller's, * after this action and its build, at the commit this one leaves on the base (`base-sha`). * - * 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. The functions it is 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. + * runMerge checks the inputs and the version against the repository's tags, then asks GitHub for + * the two merges. Everything goes through the Octokit github-script supplies: the action reads and + * writes no checkout, so a test runs it against a recorded client and asserts 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. */ const { validateVersion: validateReleaseVersion } = require('../../lib/versions.js') -const { readRepository, isNotFound, tagExists } = require('../../lib/github.js') -const { runOrFail } = require('../../lib/exec.js') +const { readRepository, isNotFound, readTag } = require('../../lib/github.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 = { - 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: the shared rule, with this action's closing line. * @@ -45,7 +31,8 @@ function validateVersion(version) { } /** - * Checks the shape of a branch input. Whether git accepts the name is checked by git, in runMerge. + * Checks the shape of a branch input. Whether the repository has the branch is checked against the + * API, in runMerge. * * @param {string} branch The input value. * @param {string} input Which input it is, for the message: `branch`, `base-branch` or @@ -79,41 +66,6 @@ function validateBranch(branch, input, { optional = false } = {}) { return trimmed } -/** - * 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. * @@ -127,24 +79,26 @@ function mergeMessage(from, into) { } /** - * Whether a failed `git merge` stopped on a conflict, as opposed to failing for any other reason. + * Whether a failed merge request is GitHub reporting a conflict, as opposed to any other refusal. + * + * The merge endpoint answers 409 for two things: a conflict between the branches, and a base that + * moved while the merge was being made. Only the first is a conflict for the caller to resolve. * - * @param {{exitCode: number, stdout: string, stderr: string}} result What the merge returned. + * @param {Error} error The Octokit error. * * @returns {boolean} True for a conflict. */ -function isConflict(result) { - return result.exitCode !== 0 && CONFLICT_OUTPUT.test(`${result.stdout}\n${result.stderr}`) +function isConflict(error) { + return error?.status === 409 && /conflict/i.test(String(error.message)) } /** * 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 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 - * 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 decides that by checking that the tag contains the + * branch. One whose tag exists without a release would make the publish 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. @@ -154,18 +108,19 @@ function isConflict(result) { * * @throws {Error} When the tag exists without a release, or a read fails for any reason but 404. * - * @returns {Promise<{tagged: boolean, releaseUrl: string|null}>} Whether the tag exists, and the - * published release's URL when it does. + * @returns {Promise<{tagged: boolean, tagSha: string|null, releaseUrl: string|null}>} Whether the + * tag exists, the commit it points at, and the published release's URL when it does. */ async function runPreflight({ github, owner, repo, version }) { - if (!(await tagExists({ github, owner, repo, version, what: 'Nothing was merged.' }))) { - return { tagged: false, releaseUrl: null } + const tagSha = await readTag({ github, owner, repo, version, what: 'Nothing was merged.' }) + if (tagSha === null) { + return { tagged: false, tagSha: null, releaseUrl: null } } try { const { data: release } = await github.rest.repos.getReleaseByTag({ owner, repo, tag: version }) - return { tagged: true, releaseUrl: release.html_url } + return { tagged: true, tagSha, releaseUrl: release.html_url } } catch (error) { if (isNotFound(error)) { throw new Error( @@ -182,19 +137,65 @@ async function runPreflight({ github, owner, repo, version }) { } /** - * Reads a file from a ref of the checkout, without checking it out. + * Checks that the repository has a branch, and reads its tip. * - * @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. + * Read before any file is read from the branch: a 404 on a file read cannot tell a missing file + * from a missing branch. * - * @returns {Promise} The contents, or null when the ref has no such file. + * @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.branch The branch name. + * + * @throws {Error} When the repository has no such branch, or the read fails. + * + * @returns {Promise} The commit the branch points at. */ -async function fileAt(exec, cwd, ref, file) { - const result = await exec.getExecOutput('git', ['show', `${ref}:${file}`], { cwd, ignoreReturnCode: true, silent: true }) +async function readBranchTip({ github, owner, repo, branch }) { + try { + return (await github.rest.repos.getBranch({ owner, repo, branch })).data.commit.sha + } catch (error) { + if (isNotFound(error)) { + throw new Error(`'${branch}' is not a branch of ${owner}/${repo}. Nothing was merged.`) + } + + throw new Error(`Reading branch ${branch} failed (HTTP ${error.status}): ${error.message}. Nothing was merged.`) + } +} + +/** + * Reads a file from a branch 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.branch The branch, known to exist. + * @param {string} options.file The path in the repository. + * + * @throws {Error} When the read fails for any reason but 404. + * + * @returns {Promise} The contents, or null when the branch has no such file (or the + * path is a directory). + */ +async function fileAt({ github, owner, repo, branch, file }) { + let data + try { + data = (await github.rest.repos.getContent({ owner, repo, path: file, ref: branch })).data + } catch (error) { + if (isNotFound(error)) { + return null + } + + throw new Error(`Reading ${file} on ${branch} failed (HTTP ${error.status}): ${error.message}. Nothing was merged.`) + } - return result.exitCode === 0 ? result.stdout : null + if (Array.isArray(data) || data.type !== 'file') { + return null + } + + return Buffer.from(data.content, 'base64').toString('utf8') } /** @@ -203,9 +204,10 @@ async function fileAt(exec, cwd, ref, file) { * 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 {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.branch The release branch. * @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 @@ -213,12 +215,12 @@ async function fileAt(exec, cwd, ref, file) { * * @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.') +async function assertBranchHoldsVersion({ github, owner, repo, branch, version }) { + const read = (file) => fileAt({ github, owner, repo, branch, file }) + const files = readVersionFiles(await read('.puprc'), 'Nothing was merged.') for (const { file, regex } of files) { - const content = await fileAt(exec, cwd, ref, file) + const content = await read(file) if (content === null) { throw new Error(`${file}, which .puprc lists under paths.versions, is not on ${branch}. Nothing was merged.`) } @@ -247,107 +249,115 @@ async function assertBranchHoldsVersion({ exec, cwd, branch, version }) { } /** - * Runs a git command in the checkout and fails with its output when it exits non-zero. + * Whether a commit contains a branch: the tag of a published version, against the release branch. * - * @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. + * @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.branch The branch. + * @param {string} options.sha The commit; a hash rather than the tag's name, which a branch of + * the same name would shadow. * - * @throws {Error} When git exits non-zero. + * @throws {Error} When the comparison fails. * - * @returns {Promise} What git printed to stdout. + * @returns {Promise} True when the commit is the branch's tip or descends from it. */ -function gitOrFail(exec, args, options, what) { - return runOrFail(exec, 'git', args, options, what) +async function commitContainsBranch({ github, owner, repo, branch, sha }) { + try { + const { data } = await github.rest.repos.compareCommitsWithBasehead({ owner, repo, basehead: `${branch}...${sha}` }) + + return data.status === 'ahead' || data.status === 'identical' + } catch (error) { + throw new Error(`Comparing ${branch} with ${sha} failed (HTTP ${error.status}): ${error.message}. Nothing was merged.`) + } } /** - * Merges one branch into the branch the checkout is on, as a merge commit, and pushes it. + * Merges one branch, or commit, into another as a merge commit, through the API. * - * 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. + * GitHub makes the merge commit under the token's identity and signs it. A base that already holds + * the head answers 204 and nothing is written, which is what a retry after a late failure finds. A + * conflict prints the MERGE_CONFLICT marker on a line of its own, where the release dashboard looks + * for it, and fails; nothing is written for that branch. * * @param {object} options + * @param {object} options.github The Octokit github-script supplies. * @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.owner The repository owner. + * @param {string} options.repo The repository name. + * @param {string} options.into The branch merged into, e.g. `main`. + * @param {string} options.head What is merged: a branch name or a commit hash. * @param {string} options.message The merge commit message. - * @param {string} options.label How to name `from` in the log, e.g. `release/M26.test`. + * @param {string} options.label How to name `head` in the log, e.g. `release/M26.test`. * - * @throws {Error} When the merge conflicts or fails, or the push fails. + * @throws {Error} When the merge conflicts or GitHub refuses it. * - * @returns {Promise} True when a merge commit was made, false when there was nothing to merge. + * @returns {Promise<{merged: boolean, sha: string}>} Whether a merge commit was made, and the commit + * `into` points at afterwards: the merge commit, or its tip 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}.` - ) - } +async function mergeInto({ github, core, owner, repo, into, head, message, label }) { + let response + try { + response = await github.rest.repos.merge({ owner, repo, base: into, head, commit_message: message }) + } catch (error) { + if (isConflict(error)) { + // core.info prints a plain line: the marker has to open an output line to count, see the README. + 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 merged into ${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 merged into ${into}.` + ) + } - if (result.exitCode !== 0) { - const output = [result.stdout, result.stderr].map((text) => text.trim()).filter(Boolean) + if (error?.status === 409) { + throw new Error( + `Merging ${label} into ${into} was refused: ${into} moved while the merge was being made ` + + `(${error.message}). Run this step again; the merges already done are kept.` + ) + } - throw new Error(`Merging ${label} into ${into} failed (exit code ${result.exitCode}):\n${output.join('\n')}`) + throw new Error(`Merging ${label} into ${into} failed (HTTP ${error?.status}): ${error?.message}`) } - const merged = !/Already up to date/.test(result.stdout) - if (!merged) { + if (response.status === 204) { 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: false, sha: await readBranchTip({ github, owner, repo, branch: into }) } + } - return merged + return { merged: true, sha: response.data.sha } } /** * 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 version has to be the one the release branch - * holds in every file its .puprc lists, so the tag matches the code. + * base branch back into develop. 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 - * lines. Both merges are `--no-ff`, so each release is one merge commit in the history. A repository - * without a develop branch leaves `develop-branch` empty, and only the first merge is made. + * lines. The API always makes a merge commit, never a fast-forward, so each release is one merge + * commit in the history. A repository without a develop branch leaves `develop-branch` empty, and + * only the first merge is made. * - * 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. + * Nothing is read from or written to a checkout: the caller checks out `base-sha` afterwards 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.github The Octokit github-script supplies, built from the token. * @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. + * @throws {Error} When an input is wrong, a branch is missing, the tag exists without a release, a + * merge conflicts, or a read or a merge fails. * - * @returns {Promise} Resolves once both branches are pushed and the outputs are set. + * @returns {Promise} Resolves once both merges are made and the outputs are set. */ -async function runMerge({ github, core, exec, env, cwd = process.cwd() }) { +async function runMerge({ github, core, env }) { const branch = validateBranch(env.INPUT_BRANCH, 'branch') const version = validateVersion(env.INPUT_VERSION) const base = validateBranch(env.INPUT_BASE_BRANCH, 'base-branch') @@ -361,99 +371,59 @@ async function runMerge({ github, core, exec, env, cwd = process.cwd() }) { throw new Error(`branch '${branch}' is also the ${branch === base ? 'base' : 'develop'} branch. Nothing was merged.`) } - const branches = develop === null ? [branch, base] : [branch, base, develop] - for (const name of branches) { - 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 { tagged, releaseUrl } = await runPreflight({ github, owner, repo, version }) - - const token = String(env.INPUT_TOKEN || '') - const authEnv = { ...env, ...gitAuthEnv(token, env.GITHUB_SERVER_URL) } - if (token) { - core.setSecret(authorizationHeader(token)) - } + const { tagged, tagSha, releaseUrl } = await runPreflight({ github, owner, repo, version }) - // 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 = branches.map((name) => `+refs/heads/${name}:refs/remotes/origin/${name}`) - if (tagged) { - refspecs.push(`+refs/tags/${version}:refs/tags/${version}`) + for (const name of develop === null ? [branch, base] : [branch, base, develop]) { + await readBranchTip({ github, owner, repo, branch: name }) } - await gitOrFail( - exec, - ['fetch', ...(shallow === 'true' ? ['--unshallow'] : []), 'origin', ...refspecs], - { cwd, env: authEnv }, - 'Fetching the branches' - ) - await assertBranchHoldsVersion({ exec, cwd, branch, version }) + await assertBranchHoldsVersion({ github, owner, repo, 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 // 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) { + if (!(await commitContainsBranch({ github, owner, repo, branch, sha: tagSha }))) { 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}: nothing to publish again.`) } - await gitOrFail(exec, ['checkout', '--quiet', '-B', base, `origin/${base}`], { cwd }, `Checking out ${base}`) - const mergedBase = await mergeAndPush({ + const { merged: mergedBase, sha: baseSha } = await mergeInto({ + github, core, - exec, - cwd, - authEnv, - from: `origin/${branch}`, + owner, + repo, into: base, + head: branch, message: mergeMessage(branch, base), label: branch, }) - // The commit the release is published at: the merge commit, or the base's tip on a retry that - // found the merge done. The caller publishes the release at it, so the tag is made at this commit - // and not at whatever the base branch points to by then; it is also the commit the caller builds - // from. - const baseSha = (await gitOrFail(exec, ['rev-parse', 'HEAD'], { cwd, silent: true }, `Reading the merged ${base} commit`)).trim() - + // base-sha is the commit the release is published at and built from: the merge commit, or the + // base's tip on a retry that found the merge done. The second merge takes that commit, not the + // base's name, so develop receives exactly what is being released even if the base moves on. let mergedDevelop = null if (develop === null) { core.info(`develop-branch is empty: ${base} is not merged back into a develop branch.`) } else { - await gitOrFail(exec, ['checkout', '--quiet', '-B', develop, `origin/${develop}`], { cwd }, `Checking out ${develop}`) - 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}`) + mergedDevelop = ( + await mergeInto({ + github, + core, + owner, + repo, + into: develop, + head: baseSha, + message: `Merge ${base} back into ${develop} (${branch})`, + label: base, + }) + ).merged } core.setOutput('branch', branch) @@ -473,9 +443,6 @@ module.exports = { runPreflight, validateVersion, validateBranch, - authorizationHeader, - gitAuthEnv, mergeMessage, isConflict, - BOT_IDENTITY, } diff --git a/.github/lib/github.js b/.github/lib/github.js index 09a03df..b6eaad6 100644 --- a/.github/lib/github.js +++ b/.github/lib/github.js @@ -37,7 +37,7 @@ function isNotFound(error) { } /** - * Reads a tag of the repository. + * Reads a tag of the repository: the commit it points at. * * @param {object} options * @param {object} options.github The Octokit github-script supplies. @@ -46,23 +46,47 @@ function isNotFound(error) { * @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 + * @throws {Error} When a read fails for any reason but 404, so an API error is never taken for an * absent tag. * - * @returns {Promise} Whether the tag exists. + * @returns {Promise} The commit the tag points at, through the tag object when the tag + * is annotated; null when there is no such tag. */ -async function tagExists({ github, owner, repo, version, what }) { +async function readTag({ github, owner, repo, version, what }) { + let ref try { - await github.rest.git.getRef({ owner, repo, ref: `tags/${version}` }) - - return true + ref = (await github.rest.git.getRef({ owner, repo, ref: `tags/${version}` })).data } catch (error) { if (isNotFound(error)) { - return false + return null } throw new Error(`Reading tag ${version} failed (HTTP ${error.status}): ${error.message}. ${what}`) } + + if (ref.object.type !== 'tag') { + return ref.object.sha + } + + // An annotated tag is an object of its own; the commit is what it points at. + try { + return (await github.rest.git.getTag({ owner, repo, tag_sha: ref.object.sha })).data.object.sha + } catch (error) { + throw new Error(`Reading the tag object of ${version} failed (HTTP ${error.status}): ${error.message}. ${what}`) + } +} + +/** + * Whether a tag of the repository exists. + * + * @param {object} options Passed to readTag. + * + * @throws {Error} When the read fails for any reason but 404. + * + * @returns {Promise} Whether the tag exists. + */ +async function tagExists(options) { + return (await readTag(options)) !== null } -module.exports = { readRepository, isNotFound, tagExists } +module.exports = { readRepository, isNotFound, readTag, tagExists } diff --git a/tests/actions/release-merge/release-merge.test.js b/tests/actions/release-merge/release-merge.test.js index 7193faf..764710f 100644 --- a/tests/actions/release-merge/release-merge.test.js +++ b/tests/actions/release-merge/release-merge.test.js @@ -2,154 +2,19 @@ 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, runPreflight, validateVersion, validateBranch, - authorizationHeader, - gitAuthEnv, mergeMessage, isConflict, - BOT_IDENTITY, } = require('../../../.github/actions/release-merge/release-merge.js') -const { coreWithOutputs, recordedRealExec, recordingExec, octokitFor } = require('../../support/actions.js') +const { coreWithOutputs, 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. - * @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', - puprc = PUPRC, -} = {}) { - 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') - 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') - 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') - 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) { - 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 -} - -/** - * 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. - * - * @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. + * The environment the manifest hands the merge step. * * @param {object} overrides Inputs to change. * @@ -157,14 +22,11 @@ function fileOn(bare, branch, name) { */ 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, } } @@ -179,36 +41,124 @@ const PUPRC = { }, } +/** The tips of the three branches, as a plugin repository has them after release prep. */ +const TIP = { + 'release/M26.test': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + main: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + develop: 'cccccccccccccccccccccccccccccccccccccccc', +} + +/** The merge commits GitHub answers with: the release on main, then main on develop. */ +const MERGED = { + main: 'dddddddddddddddddddddddddddddddddddddddd', + develop: 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', +} + +/** The commit a published 4.17.0 is tagged at, and the tag object of an annotated one. */ +const TAG_COMMIT = 'ffffffffffffffffffffffffffffffffffffffff' +const TAG_OBJECT = '1111111111111111111111111111111111111111' + +const REPO = '/repos/stellarwp/plugin' + /** 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', + tag: `${REPO}/git/ref/tags%2F4.17.0`, + tagObject: `${REPO}/git/tags/${TAG_OBJECT}`, + releaseByTag: `${REPO}/releases/tags/4.17.0`, + branch: (name) => `${REPO}/branches/${encodeURIComponent(name)}`, + file: (name, branch = 'release/M26.test') => `${REPO}/contents/${name}?ref=${encodeURIComponent(branch)}`, + compare: (sha) => `${REPO}/compare/${encodeURIComponent(`release/M26.test...${sha}`)}`, + merges: `${REPO}/merges`, } 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.tag, body: { ref: 'refs/tags/4.17.0', object: { sha: TAG_COMMIT, type: 'commit' } } }, { method: 'GET', path: API.releaseByTag, body: { html_url: RELEASE_URL } }, ] /** - * Each call as `command arg arg…`, for asserting the order the action worked in. + * Rules for the branches the action reads: each answers with its tip. * - * @param {object[]} calls The calls an exec recorded. + * @param {string[]} names The branches the repository has. * - * @returns {string[]} One line per call. + * @returns {object[]} One rule per branch. */ -function sequence(calls) { - return calls.map((call) => [call.command, ...call.args].join(' ')) +function branches(names = Object.keys(TIP)) { + return names.map((name) => ({ method: 'GET', path: API.branch(name), body: { name, commit: { sha: TIP[name] } } })) } /** - * Each request as `METHOD path`, for asserting what the action asked the API. + * Rules for the files the action reads from the release branch, as the contents endpoint sends + * them. A file set to null is absent: no rule, so the read answers 404. + * + * @param {object} contents File name to contents; the fixture's defaults hold 4.17.0 everywhere. + * + * @returns {object[]} One rule per present file. + */ +function files(contents = {}) { + const all = { + '.puprc': JSON.stringify(PUPRC), + 'plugin.php': 'Version: 4.17.0\n', + 'readme.txt': 'Stable tag: 4.17.0\n', + ...contents, + } + + return Object.entries(all) + .filter(([, body]) => body !== null) + .map(([name, body]) => ({ + method: 'GET', + path: API.file(name), + body: { type: 'file', path: name, encoding: 'base64', content: Buffer.from(body).toString('base64') }, + })) +} + +/** What the merge endpoint answers: a merge commit, nothing to merge, or a refusal. */ +const MERGE = { + main: { status: 201, body: { sha: MERGED.main } }, + develop: { status: 201, body: { sha: MERGED.develop } }, + done: { status: 204 }, + conflict: { status: 409, body: { message: 'Merge conflict' } }, + moved: { status: 409, body: { message: 'Base branch was modified. Review and try the merge again.' } }, +} + +/** + * The rule for the merge endpoint, answering each request with the next entry. + * + * @param {object[]} responses What each merge request is answered with, in order. + * + * @returns {object} The rule. + */ +function merges(responses) { + return { method: 'POST', path: API.merges, responses } +} + +/** + * The rules of the common case: no tag, three branches, a prepared release branch, both merges made. + * + * @param {object} options + * @param {object[]} options.tag Rules for the tag and release reads; none for an untagged version. + * @param {string[]} options.branches The branches the repository has. + * @param {object} options.files Files of the release branch to change from the fixture. + * @param {object[]} options.merges What the merge requests are answered with. + * @param {object[]} options.extra Any other rule. + * + * @returns {object[]} The rules. + */ +function repositoryWith({ + tag = [], + branches: names = Object.keys(TIP), + files: contents = {}, + merges: answers = [MERGE.main, MERGE.develop], + extra = [], +} = {}) { + return [...tag, ...branches(names), ...files(contents), merges(answers), ...extra] +} + +/** + * Each request as `METHOD path`, for asserting what the action asked the API and in which order. * * @param {object[]} requests The requests recordingFetch recorded. * @@ -218,6 +168,17 @@ function requested(requests) { return requests.map((request) => `${request.method} ${request.path}`) } +/** + * The bodies of the merge requests, in order. + * + * @param {object[]} requests The requests recordingFetch recorded. + * + * @returns {object[]} `{base, head, commit_message}` per merge asked for. + */ +function mergesAsked(requests) { + return requests.filter((request) => request.method === 'POST' && request.path === API.merges).map((request) => request.body) +} + describe('release-merge', () => { describe('validateVersion', () => { it('accepts three or four numeric parts, the shape the dashboard publishes', () => { @@ -262,31 +223,6 @@ describe('release-merge', () => { }) }) - 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') @@ -294,42 +230,49 @@ describe('release-merge', () => { }) 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('recognises the 409 GitHub answers a conflicting merge with', () => { + assert.strictEqual(isConflict({ status: 409, message: 'Merge conflict' }), 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) + it('does not read the 409 for a base that moved, or any other failure, as a conflict', () => { + assert.strictEqual(isConflict({ status: 409, message: 'Base branch was modified. Review and try the merge again.' }), false) + assert.strictEqual(isConflict({ status: 404, message: 'Base does not exist' }), false) + assert.strictEqual(isConflict({ status: 422, message: 'Validation Failed' }), false) }) }) describe('runPreflight', () => { it('finds nothing when the version was never tagged', async () => { - const { github, requests } = await octokitFor(UNTAGGED) + const { github, requests } = await octokitFor([]) const result = await runPreflight({ github, owner: 'stellarwp', repo: 'plugin', version: '4.17.0' }) - assert.deepStrictEqual(result, { tagged: false, releaseUrl: null }) + assert.deepStrictEqual(result, { tagged: false, tagSha: null, 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 () => { + it('finds the published release and the tagged commit 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, { tagged: true, releaseUrl: RELEASE_URL }) + assert.deepStrictEqual(result, { tagged: true, tagSha: TAG_COMMIT, releaseUrl: RELEASE_URL }) assert.deepStrictEqual(requested(requests), [`GET ${API.tag}`, `GET ${API.releaseByTag}`]) }) + it('reads the commit behind an annotated tag, since the ref points at the tag object', async () => { + const { github, requests } = await octokitFor([ + { method: 'GET', path: API.tag, body: { ref: 'refs/tags/4.17.0', object: { sha: TAG_OBJECT, type: 'tag' } } }, + { method: 'GET', path: API.tagObject, body: { sha: TAG_OBJECT, object: { sha: TAG_COMMIT, type: 'commit' } } }, + PUBLISHED[1], + ]) + + const result = await runPreflight({ github, owner: 'stellarwp', repo: 'plugin', version: '4.17.0' }) + + assert.strictEqual(result.tagSha, TAG_COMMIT) + assert.deepStrictEqual(requested(requests), [`GET ${API.tag}`, `GET ${API.tagObject}`, `GET ${API.releaseByTag}`]) + }) + it('refuses a tag that exists without a published release, before anything is merged', async () => { const { github } = await octokitFor([PUBLISHED[0]]) @@ -350,340 +293,268 @@ describe('release-merge', () => { }) 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) + it('merges the release into the base, then the merged commit back into develop, as two merge commits', async (t) => { + const { github, requests } = await octokitFor(repositoryWith()) 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') + await runMerge({ github, core, env: mergeEnv() }) + + assert.deepStrictEqual(requested(requests), [ + `GET ${API.tag}`, + `GET ${API.branch('release/M26.test')}`, + `GET ${API.branch('main')}`, + `GET ${API.branch('develop')}`, + `GET ${API.file('.puprc')}`, + `GET ${API.file('plugin.php')}`, + `GET ${API.file('readme.txt')}`, + `POST ${API.merges}`, + `POST ${API.merges}`, + ]) + assert.deepStrictEqual(mergesAsked(requests), [ + { base: 'main', head: 'release/M26.test', commit_message: 'Merge release/M26.test into main' }, + // The commit just made on main, not main by name: develop gets exactly what is released. + { base: 'develop', head: MERGED.main, commit_message: 'Merge main back into develop (release/M26.test)' }, + ]) assert.deepStrictEqual(outputs(), { branch: 'release/M26.test', version: '4.17.0', 'base-branch': 'main', - // The merge commit on the remote: what the caller builds from and tags. - 'base-sha': git(bare, 'rev-parse', 'main'), + // The merge commit: what the caller checks out, builds from and tags. + 'base-sha': MERGED.main, 'develop-branch': 'develop', conflict: '', }) - assert.match(summary(), /release\/M26\.test.*main.*develop/s) + assert.match(summary(), /merged `release\/M26\.test` into `main`, merged `main` into `develop`/) }) it('merges only into the base when develop-branch is empty, for a repository without develop', async (t) => { - const bare = remoteWith() - const cwd = checkoutOf(bare) - const develop = git(bare, 'rev-parse', 'develop') - const { github } = await octokitFor(UNTAGGED) + const { github, requests } = await octokitFor(repositoryWith({ branches: ['release/M26.test', 'main'], merges: [MERGE.main] })) const { core, outputs, summary, logged } = await coreWithOutputs(t) - const { exec, calls } = await recordedRealExec() - await runMerge({ github, core, exec, env: mergeEnv({ INPUT_DEVELOP_BRANCH: '' }), cwd }) + await runMerge({ github, core, env: mergeEnv({ INPUT_DEVELOP_BRANCH: '' }) }) - assert.strictEqual(fileOn(bare, 'main', 'plugin.php'), 'Version: 4.17.0') - // develop was neither fetched nor touched. - assert.strictEqual(git(bare, 'rev-parse', 'develop'), develop) - assert.ok(!sequence(calls).some((line) => line.includes('refs/heads/develop')), sequence(calls).join('\n')) - assert.strictEqual(calls.filter((call) => call.args[0] === 'push').length, 1) - assert.strictEqual(git(cwd, 'symbolic-ref', '--short', 'HEAD'), 'main') + assert.ok(!requested(requests).some((line) => line.includes('develop')), requested(requests).join('\n')) + assert.strictEqual(mergesAsked(requests).length, 1) + assert.strictEqual(outputs()['base-sha'], MERGED.main) assert.strictEqual(outputs()['develop-branch'], '') assert.strictEqual(outputs().conflict, '') assert.match(logged(), /develop-branch is empty/) assert.match(summary(), /no develop branch/) }) - 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])) + it('does nothing on a retry after a late failure: both merges answer already merged', async (t) => { + // The publish step tagged the first run's merge commit, so the retry finds the tag with its + // release, and main's tip is that commit. + const { github, requests } = await octokitFor( + repositoryWith({ + tag: [ + { method: 'GET', path: API.tag, body: { ref: 'refs/tags/4.17.0', object: { sha: MERGED.main, type: 'commit' } } }, + PUBLISHED[1], + ], + merges: [MERGE.done, MERGE.done], + extra: [{ method: 'GET', path: API.compare(MERGED.main), body: { status: 'ahead' } }], + }).map((rule) => (rule.path === API.branch('main') ? { ...rule, body: { name: 'main', commit: { sha: MERGED.main } } } : rule)) + ) + const { core, outputs, logged } = await coreWithOutputs(t) - 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')) - }) + await runMerge({ github, core, env: mergeEnv() }) - 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') - // 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: 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) - assert.match(retry.logged(), /main already has release\/M26\.test/) - assert.strictEqual(retry.outputs().conflict, '') + assert.strictEqual(mergesAsked(requests).length, 2) + assert.match(logged(), /main already has release\/M26\.test: nothing to merge/) + assert.match(logged(), /develop already has main: nothing to merge/) + assert.strictEqual(outputs().conflict, '') // Nothing was merged, so the commit to publish at is the base's tip, the one holding the release. - assert.strictEqual(retry.outputs()['base-sha'], main) + assert.strictEqual(outputs()['base-sha'], MERGED.main) + // The second merge still asks for that commit, which develop already has. + assert.strictEqual(mergesAsked(requests)[1].head, MERGED.main) }) it('says so when the release is already published from this branch, so the publish has nothing to do', 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 { github, requests } = await octokitFor( + repositoryWith({ + tag: PUBLISHED, + extra: [{ method: 'GET', path: API.compare(TAG_COMMIT), body: { status: 'ahead' } }], + }) + ) const { core, logged } = await coreWithOutputs(t) - const { exec } = await recordedRealExec() - await runMerge({ github, core, exec, env: mergeEnv(), cwd }) + await runMerge({ github, core, env: mergeEnv() }) - assert.match(logged(), /4\.17\.0 is already published.*nothing to publish again/s) - assert.strictEqual(fileOn(bare, 'develop', 'plugin.php'), 'Version: 4.17.0') + assert.match(logged(), /4\.17\.0 is already published \(.*releases\/tag\/4\.17\.0\) from release\/M26\.test: nothing to publish again/) + assert.ok(requested(requests).includes(`GET ${API.compare(TAG_COMMIT)}`), requested(requests).join('\n')) + assert.strictEqual(mergesAsked(requests).length, 2) }) 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 { github, requests } = await octokitFor( + repositoryWith({ + tag: PUBLISHED, + extra: [{ method: 'GET', path: API.compare(TAG_COMMIT), body: { status: 'diverged' } }], + }) + ) const { core } = await coreWithOutputs(t) - const { exec, calls } = await recordedRealExec() await assert.rejects( - runMerge({ github, core, exec, env: mergeEnv(), cwd }), + runMerge({ github, core, env: mergeEnv() }), /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')) + assert.deepStrictEqual(mergesAsked(requests), []) }) - 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) + it('prints the MERGE_CONFLICT marker and merges nothing when the release conflicts with the base', async (t) => { + const { github, requests } = await octokitFor(repositoryWith({ merges: [MERGE.conflict] })) 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 + runMerge({ github, core, env: mergeEnv() }), + /release\/M26\.test into main stopped on a conflict.*Nothing was merged into 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) + assert.strictEqual(mergesAsked(requests).length, 1, 'develop was never asked for') + }) + + it('prints the marker and names develop when the merged base conflicts with develop', async (t) => { + const { github, requests } = await octokitFor(repositoryWith({ merges: [MERGE.main, MERGE.conflict] })) 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 + runMerge({ github, core, env: mergeEnv() }), + /main into develop stopped on a conflict.*merges already done are kept.*Nothing was merged into 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'), '') + assert.deepStrictEqual(mergesAsked(requests).map((merge) => merge.base), ['main', 'develop']) + }) + + it('fails without the marker when GitHub refuses the merge because the base moved', async (t) => { + const { github } = await octokitFor(repositoryWith({ merges: [MERGE.moved] })) + const { core, outputs, logged } = await coreWithOutputs(t) + + await assert.rejects( + runMerge({ github, core, env: mergeEnv() }), + /main moved while the merge was being made.*Base branch was modified.*Run this step again/s + ) + + assert.doesNotMatch(logged(), /MERGE_CONFLICT/) + assert.strictEqual(outputs().conflict, undefined) }) 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 { github, requests } = await octokitFor(repositoryWith()) 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 }), + runMerge({ github, core, env: mergeEnv({ INPUT_VERSION: '4.18.0' }) }), /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')) + assert.deepStrictEqual(mergesAsked(requests), []) }) 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 { github, requests } = await octokitFor(repositoryWith({ files: { 'readme.txt': 'Stable tag: 4.16.0\n' } })) 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/ - ) + await assert.rejects(runMerge({ github, core, env: mergeEnv() }), /release\/M26\.test holds 4\.16\.0 in readme\.txt, not 4\.17\.0/) + assert.deepStrictEqual(mergesAsked(requests), []) + }) + + it('reads the version files from the release branch, not from the base', async (t) => { + const { github, requests } = await octokitFor(repositoryWith()) + const { core } = await coreWithOutputs(t) + + await runMerge({ github, core, env: mergeEnv() }) + + const reads = requests.filter((request) => request.path.startsWith(`${REPO}/contents/`)) + assert.strictEqual(reads.length, 3) + assert.ok(reads.every((request) => request.path.endsWith('?ref=release%2FM26.test')), requested(reads).join('\n')) }) 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 { github } = await octokitFor(repositoryWith({ files: { 'readme.txt': 'No stable tag here\n' } })) const { core } = await coreWithOutputs(t) - const { exec } = await recordedRealExec() await assert.rejects( - runMerge({ github, core, exec, env: mergeEnv(), cwd }), + runMerge({ github, core, env: mergeEnv() }), /readme\.txt on release\/M26\.test holds no version.*\(Stable tag: \)\(\.\+\)/s ) }) 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 puprc = { paths: { versions: [{ file: 'plugin.php', regex: '(Version: )(?<' }] } } + const { github, requests } = await octokitFor(repositoryWith({ files: { '.puprc': JSON.stringify(puprc) } })) const { core } = await coreWithOutputs(t) - const { exec, calls } = await recordedRealExec() await assert.rejects( - runMerge({ github, core, exec, env: mergeEnv(), cwd }), + runMerge({ github, core, env: mergeEnv() }), /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))) + assert.deepStrictEqual(mergesAsked(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. - 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) + it('refuses a branch whose version file is missing, naming it', async (t) => { + const { github } = await octokitFor(repositoryWith({ files: { 'readme.txt': null } })) 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) + await assert.rejects( + runMerge({ github, core, env: mergeEnv() }), + /readme\.txt, which \.puprc lists under paths\.versions, is not on release\/M26\.test/ + ) }) - 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) + it('refuses a branch without a .puprc, since the version it holds cannot be read', async (t) => { + const { github } = await octokitFor(repositoryWith({ files: { '.puprc': null } })) 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') + await assert.rejects(runMerge({ github, core, env: mergeEnv() }), /No \.puprc on the branch.*Nothing was merged/s) }) - 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]]) + it('refuses a branch the repository does not have, before reading a file or merging', async (t) => { + const { github, requests } = await octokitFor(repositoryWith({ branches: ['main', 'develop'] })) const { core } = await coreWithOutputs(t) - const { exec, calls } = await recordedRealExec() - await assert.rejects(runMerge({ github, core, exec, env: mergeEnv(), cwd }), /no published release/) + await assert.rejects( + runMerge({ github, core, env: mergeEnv() }), + /'release\/M26\.test' is not a branch of stellarwp\/plugin\. Nothing was merged/ + ) - assert.ok(!sequence(calls).some((line) => /^git (fetch|push|checkout)| merge --no-ff /.test(line)), sequence(calls).join('\n')) + assert.deepStrictEqual(requested(requests), [`GET ${API.tag}`, `GET ${API.branch('release/M26.test')}`]) }) - it('refuses a branch name git would refuse, before asking the API anything', async (t) => { - const { github, requests } = await octokitFor(UNTAGGED) + it('refuses a tag without a release before reading any branch', async (t) => { + const { github, requests } = await octokitFor(repositoryWith({ tag: [PUBLISHED[0]] })) 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/ - ) + await assert.rejects(runMerge({ github, core, env: mergeEnv() }), /no published release/) - assert.deepStrictEqual(requests, []) + assert.deepStrictEqual(requested(requests), [`GET ${API.tag}`, `GET ${API.releaseByTag}`]) }) - it('refuses the same branch twice, which would merge a branch into itself', async (t) => { - const { github } = await octokitFor(UNTAGGED) + it('refuses the same branch twice, which would merge a branch into itself, before asking the API anything', async (t) => { + const { github, requests } = await octokitFor(repositoryWith()) 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() }), + runMerge({ github, core, env: mergeEnv({ INPUT_DEVELOP_BRANCH: 'main' }) }), /base-branch and develop-branch are both 'main'/ ) - assert.deepStrictEqual(calls, []) + await assert.rejects(runMerge({ github, core, env: mergeEnv({ INPUT_BRANCH: 'develop' }) }), /'develop' is also the develop branch/) + assert.deepStrictEqual(requests, []) }) - it('fails when GITHUB_REPOSITORY is missing, since it does not know whose tags to read', async (t) => { - const { github } = await octokitFor(UNTAGGED) + it('fails when GITHUB_REPOSITORY is missing, since it does not know whose branches to merge', async (t) => { + const { github, requests } = await octokitFor(repositoryWith()) 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/ - ) + await assert.rejects(runMerge({ github, core, env: mergeEnv({ GITHUB_REPOSITORY: '' }) }), /GITHUB_REPOSITORY is not set/) + assert.deepStrictEqual(requests, []) }) }) - }) diff --git a/tests/support/actions.js b/tests/support/actions.js index 0c9f97c..fd0fb50 100644 --- a/tests/support/actions.js +++ b/tests/support/actions.js @@ -32,6 +32,12 @@ const path = require('node:path') * @returns {Response} The response for the fetch to return. */ function jsonResponse({ status = 200, body = {}, headers = {} }) { + // A 204 carries no body, and Response refuses one: the merge endpoint answers it when the base + // already contains the head. + if (status === 204 || status === 205 || status === 304) { + return new Response(null, { status, headers }) + } + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers },