From 184b01c175040ef2ccf3f1e89f3e817ff3fdf07a Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Thu, 8 Oct 2026 14:42:37 -0300 Subject: [PATCH 01/27] Add an exec stand-in and a job summary reader to the test harness --- tests/support/actions.js | 106 +++++++++++++++++++++++++++++++--- tests/support/actions.test.js | 61 ++++++++++++++++++- 2 files changed, 158 insertions(+), 9 deletions(-) diff --git a/tests/support/actions.js b/tests/support/actions.js index 9b4765d..1f99dcd 100644 --- a/tests/support/actions.js +++ b/tests/support/actions.js @@ -12,6 +12,10 @@ * outputs are read back the way a later workflow step would see them. * * Both packages are ESM only, so they are loaded with dynamic import and these factories are async. + * + * `exec` is the one stand-in. An action that shells out (git, php) would otherwise need those tools + * and a network at test time, so recordingExec answers from rules the way recordingFetch does, and + * records the calls for the test to assert. */ const fs = require('node:fs') @@ -182,6 +186,30 @@ function captureStdout(t) { return () => written.join('') } +/** The job summary file for this process, created on first use. */ +let summaryFile = null + +/** + * Collects what is written to the job summary for the length of one test. + * + * core.summary resolves GITHUB_STEP_SUMMARY on its first write and keeps the path for the life of + * the process, so a fresh file per test would be ignored after the first one. There is one file + * instead, and each test reads only what was appended after it began. + * + * @returns {Function} Reads back everything written to the summary since this call. + */ +function captureSummary() { + if (!summaryFile) { + summaryFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'toolbox-summary-')), 'summary') + fs.writeFileSync(summaryFile, '') + process.env.GITHUB_STEP_SUMMARY = summaryFile + } + + const from = fs.statSync(summaryFile).size + + return () => fs.readFileSync(summaryFile, 'utf8').slice(from) +} + /** * The real @actions/core, pointed at a fresh GITHUB_OUTPUT file, with its log captured. * @@ -194,10 +222,13 @@ function captureStdout(t) { * keeps the run clean and lets a test assert what was logged. stdout is restored when the test ends, * before the runner reports the result. * + * The job summary is collected too, so an action that writes one can be asserted on. + * * @param {object} t The node:test context, used to restore stdout afterwards. * - * @returns {Promise<{core: object, outputs: Function, logged: Function}>} The toolkit, a reader for - * its outputs, and a reader for everything it wrote to the log. + * @returns {Promise<{core: object, outputs: Function, logged: Function, summary: Function}>} The + * toolkit, and readers for its outputs, for everything it wrote to the log and for what it + * wrote to the job summary. */ async function coreWithOutputs(t) { const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'toolbox-output-')), 'output') @@ -206,7 +237,59 @@ async function coreWithOutputs(t) { const core = await import('@actions/core') - return { core, outputs: () => readOutputs(file), logged: captureStdout(t) } + return { core, outputs: () => readOutputs(file), logged: captureStdout(t), summary: captureSummary() } +} + +/** + * Stands in for @actions/exec: answers each call from a list of rules and records it. + * + * A rule matches on `command` and, when it has one, on `args`, a RegExp tested against the + * arguments joined by spaces. The first match wins. It answers with `exitCode` (default 0), + * `stdout` and `stderr`, and runs `effect(args, options)` first when it has one, which is how a test + * stands in for a file the command would have written. + * + * A call no rule matches answers exit code 127, the shell's "command not found", so a call the test + * did not plan for fails rather than passing silently. + * + * Like the real package, a non-zero exit rejects unless the caller passed `ignoreReturnCode`. + * + * @param {object[]} rules `{command, args, exitCode, stdout, stderr, effect}`. + * + * @returns {{exec: object, calls: object[]}} The stand-in, with `exec` and `getExecOutput`, and the + * calls it recorded as `{command, args, options}`. + */ +function recordingExec(rules = []) { + const calls = [] + + const getExecOutput = async (command, args = [], options = {}) => { + calls.push({ command, args, options }) + + const rule = rules.find( + (candidate) => + candidate.command === command && (!candidate.args || candidate.args.test(args.join(' '))) + ) + + if (rule?.effect) { + await rule.effect(args, options) + } + + const result = rule + ? { exitCode: rule.exitCode ?? 0, stdout: rule.stdout ?? '', stderr: rule.stderr ?? '' } + : { exitCode: 127, stdout: '', stderr: `${command}: no test rule answers this call` } + + if (result.exitCode !== 0 && !options.ignoreReturnCode) { + throw new Error(`The process '${command}' failed with exit code ${result.exitCode}`) + } + + return result + } + + const exec = { + getExecOutput, + exec: async (command, args, options) => (await getExecOutput(command, args, options)).exitCode, + } + + return { exec, calls } } /** @@ -216,14 +299,21 @@ async function coreWithOutputs(t) { * @param {object[]} rules Passed to recordingFetch. * * @returns {Promise<{github: object, requests: object[], core: object, outputs: Function, - * logged: Function}>} Everything a test needs: the client, the requests it records, the - * toolkit, and readers for its outputs and its log. + * logged: Function, summary: Function}>} Everything a test needs: the client, the + * requests it records, the toolkit, and readers for its outputs, its log and its summary. */ async function actionsFor(t, rules = []) { const { github, requests } = await octokitFor(rules) - const { core, outputs, logged } = await coreWithOutputs(t) + const { core, outputs, logged, summary } = await coreWithOutputs(t) - return { github, requests, core, outputs, logged } + return { github, requests, core, outputs, logged, summary } } -module.exports = { actionsFor, octokitFor, coreWithOutputs, recordingFetch, readOutputs } +module.exports = { + actionsFor, + octokitFor, + coreWithOutputs, + recordingFetch, + recordingExec, + readOutputs, +} diff --git a/tests/support/actions.test.js b/tests/support/actions.test.js index 40c021f..2bb10f7 100644 --- a/tests/support/actions.test.js +++ b/tests/support/actions.test.js @@ -3,7 +3,7 @@ const { describe, it } = require('node:test') const assert = require('node:assert') -const { actionsFor } = require('./actions.js') +const { actionsFor, coreWithOutputs, recordingExec } = require('./actions.js') describe('tests/support/actions', () => { describe('the captured log', () => { @@ -32,4 +32,63 @@ describe('tests/support/actions', () => { assert.equal(first.logged(), '', 'the earlier one stopped when the second began') }) }) + + describe('the job summary', () => { + it('holds what the toolkit wrote to it during the test', async (t) => { + const { core, summary } = await coreWithOutputs(t) + + await core.summary.addRaw('a summary line').write() + + assert.match(summary(), /a summary line/) + }) + }) + + describe('recordingExec', () => { + it('answers from the first matching rule and records every call', async () => { + const { exec, calls } = recordingExec([ + { command: 'git', args: /^status/, stdout: ' M a.php\n' }, + { command: 'git', stdout: 'any other git call' }, + ]) + + const status = await exec.getExecOutput('git', ['status', '--porcelain']) + const other = await exec.getExecOutput('git', ['rev-parse', 'HEAD'], { cwd: '/repo' }) + + assert.deepEqual(status, { exitCode: 0, stdout: ' M a.php\n', stderr: '' }) + assert.equal(other.stdout, 'any other git call') + assert.deepEqual(calls, [ + { command: 'git', args: ['status', '--porcelain'], options: {} }, + { command: 'git', args: ['rev-parse', 'HEAD'], options: { cwd: '/repo' } }, + ]) + }) + + it('fails a non-zero exit the way @actions/exec does, unless the caller ignores it', async () => { + const { exec } = recordingExec([{ command: 'php', exitCode: 1, stderr: 'boom' }]) + + await assert.rejects(exec.getExecOutput('php', ['x']), /The process 'php' failed with exit code 1/) + await assert.rejects(exec.exec('php', ['x']), /The process 'php' failed with exit code 1/) + + const ignored = await exec.getExecOutput('php', ['x'], { ignoreReturnCode: true }) + + assert.deepEqual(ignored, { exitCode: 1, stdout: '', stderr: 'boom' }) + }) + + it('answers a call no rule planned for as a missing command, never as a success', async () => { + const { exec } = recordingExec([]) + + const result = await exec.getExecOutput('curl', ['-fsSL'], { ignoreReturnCode: true }) + + assert.equal(result.exitCode, 127) + }) + + it('runs a rule effect, so a test can stand in for what the command writes', async () => { + const written = [] + const { exec } = recordingExec([ + { command: 'php', effect: (args, options) => written.push([args[0], options.cwd]) }, + ]) + + await exec.exec('php', ['pup.phar'], { cwd: '/repo' }) + + assert.deepEqual(written, [['pup.phar', '/repo']]) + }) + }) }) From 4b21bed8285fe138d9c1744d9de5cabb9edc861b Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Thu, 8 Oct 2026 14:46:50 -0300 Subject: [PATCH 02/27] Leave the test runner's own reports out of the captured log --- tests/support/actions.js | 19 ++++++++++++++++++- tests/support/actions.test.js | 12 +++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/tests/support/actions.js b/tests/support/actions.js index 1f99dcd..35a02a4 100644 --- a/tests/support/actions.js +++ b/tests/support/actions.js @@ -139,6 +139,22 @@ function readOutputs(file) { let realStdoutWrite = null let collecting = null +/** + * Whether a stdout write is the action's log, which a test collects, or the test runner's own. + * + * node --test runs each file in a child process that reports its results to the parent over stdout, + * as serialized Buffers written whenever the runner flushes, which can be in the middle of a later + * test that awaits. Collecting those swallowed the reports of every test before it, and the file + * counted as a single test. @actions/core writes its log as strings, so only strings are collected. + * + * @param {string|Uint8Array} chunk What was written. + * + * @returns {boolean} True for a write to collect. + */ +function isLogWrite(chunk) { + return typeof chunk === 'string' +} + /** * Collects what is written to stdout for the length of one test. * @@ -161,7 +177,7 @@ function captureStdout(t) { realStdoutWrite = process.stdout.write.bind(process.stdout) process.stdout.write = (chunk, encoding, callback) => { - if (!collecting) { + if (!collecting || !isLogWrite(chunk)) { return realStdoutWrite(chunk, encoding, callback) } @@ -316,4 +332,5 @@ module.exports = { recordingFetch, recordingExec, readOutputs, + isLogWrite, } diff --git a/tests/support/actions.test.js b/tests/support/actions.test.js index 2bb10f7..4788a1c 100644 --- a/tests/support/actions.test.js +++ b/tests/support/actions.test.js @@ -3,7 +3,7 @@ const { describe, it } = require('node:test') const assert = require('node:assert') -const { actionsFor, coreWithOutputs, recordingExec } = require('./actions.js') +const { actionsFor, coreWithOutputs, recordingExec, isLogWrite } = require('./actions.js') describe('tests/support/actions', () => { describe('the captured log', () => { @@ -33,6 +33,16 @@ describe('tests/support/actions', () => { }) }) + describe('isLogWrite', () => { + it("collects the toolkit's string writes and leaves the runner's Buffers alone", () => { + // The runner reports results as Buffers over the same stdout. Collecting them swallowed the + // report of every test that finished while a later one was awaiting. + assert.equal(isLogWrite('::warning::a warning\n'), true) + assert.equal(isLogWrite(Buffer.from('report')), false) + assert.equal(isLogWrite(new Uint8Array([1, 2])), false) + }) + }) + describe('the job summary', () => { it('holds what the toolkit wrote to it during the test', async (t) => { const { core, summary } = await coreWithOutputs(t) From 5b35c2a48ed028629bb778c95edbbec1fb065870 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Thu, 8 Oct 2026 14:46:50 -0300 Subject: [PATCH 03/27] Add the release-prep logic: version, date and .puprc checks, pup, commit and push --- .github/actions/release-prep/release-prep.js | 522 +++++++++++++++ package.json | 1 + .../actions/release-prep/release-prep.test.js | 619 ++++++++++++++++++ 3 files changed, 1142 insertions(+) create mode 100644 .github/actions/release-prep/release-prep.js create mode 100644 tests/actions/release-prep/release-prep.test.js diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js new file mode 100644 index 0000000..5ff9f98 --- /dev/null +++ b/.github/actions/release-prep/release-prep.js @@ -0,0 +1,522 @@ +'use strict' + +/** + * Prepares a release branch: bumps the version, replaces the TBD placeholders, and commits and + * pushes what the changelog step wrote alongside them. + * + * The action runs in two github-script steps with the changelogger action between them, so there + * are two entry points. runPrepare validates everything and runs pup; runCommit commits and pushes. + * The functions they are built from are pure, apart from the reads of .puprc and package.json and + * the commands run through `exec`, so a test can pass a stand-in and assert the calls. + */ + +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +/** + * Three or four numeric parts. It is what the stellarwp changelog versioning accepts, minus its + * blind spot: it reads an empty part as 0, so it would take 2.1..21. A version of this shape is + * also always a valid tag name. + */ +const VERSION_PATTERN = /^\d+\.\d+\.\d+(\.\d+)?$/ + +/** A pup release number, which goes into the download URL. */ +const PUP_VERSION_PATTERN = /^\d+\.\d+\.\d+$/ + +/** A calendar date as the changelog writing strategies expect it. */ +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ + +/** The identity of the bot GitHub shows for commits made with the default token. */ +const COMMIT_AUTHOR = { + name: 'github-actions[bot]', + email: '41898282+github-actions[bot]@users.noreply.github.com', +} + +/** + * Checks the version to prepare. + * + * @param {string} version The `version` input. + * + * @throws {Error} When it is empty, or not three or four numeric parts. + * + * @returns {string} The version, without surrounding whitespace. + */ +function validateVersion(version) { + 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 changed." + ) + } + + if (!VERSION_PATTERN.test(trimmed)) { + throw new Error( + `'${trimmed}' is not a version this action can prepare. It takes 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 is not supported, ' + + 'because the stellarwp changelog versioning rejects it. Nothing was changed.' + ) + } + + return trimmed +} + +/** + * Works out the date to write in the changelog. + * + * The changelogger action writes the date exactly as it is given: `today` would land in the + * changelog as the word, and an empty value as an empty date. So both are resolved here. + * + * @param {string} date The `date` input: empty, `today`, or YYYY-MM-DD. + * @param {Date} now The current time, for tests. + * + * @throws {Error} When it is anything else, or not a real calendar date. + * + * @returns {string} The date as YYYY-MM-DD, today's in UTC for an empty value or `today`. + */ +function resolveDate(date, now = new Date()) { + const trimmed = String(date ?? '').trim() + + if (trimmed === '' || trimmed.toLowerCase() === 'today') { + return now.toISOString().slice(0, 10) + } + + // A real date round-trips through Date unchanged; 2026-02-30 comes back as March 2nd. One out of + // range, such as 2026-13-45, is no date at all and would make toISOString throw, so Date.parse + // rules it out first. + const isRealDate = + DATE_PATTERN.test(trimmed) && + !Number.isNaN(Date.parse(trimmed)) && + new Date(`${trimmed}T00:00:00Z`).toISOString().slice(0, 10) === trimmed + + if (!isRealDate) { + throw new Error( + `'${trimmed}' is not a date. Pass YYYY-MM-DD, or leave it empty or 'today' for today's date ` + + 'in UTC. Nothing was changed.' + ) + } + + return trimmed +} + +/** + * Checks the shape of the branch to push to. Whether git accepts the name is checked by git, in + * runPrepare. + * + * @param {string} ref The `ref` input. + * + * @throws {Error} When it is empty or a full ref. + * + * @returns {string} The branch name, without surrounding whitespace. + */ +function validateRef(ref) { + const trimmed = String(ref ?? '').trim() + + if (trimmed === '') { + throw new Error('No ref was given: pass the release branch to prepare, e.g. release/4.17.0.') + } + + // The push writes refs/heads/, so a full ref would create a branch named refs/heads/…. + if (trimmed.startsWith('refs/')) { + throw new Error( + `Refusing to prepare '${trimmed}': pass the branch name, e.g. ` + + `'${trimmed.replace(/^refs\/heads\//, '')}'.` + ) + } + + return trimmed +} + +/** + * Checks the pup release to download. + * + * @param {string} version The `pup-version` input. + * + * @throws {Error} When it is not a release number. + * + * @returns {string} The version. + */ +function validatePupVersion(version) { + const trimmed = String(version ?? '').trim() + + if (!PUP_VERSION_PATTERN.test(trimmed)) { + throw new Error(`pup-version '${trimmed}' is not a pup release number, such as 2.0.0.`) + } + + return trimmed +} + +/** + * Reads what the action needs from the repository's .puprc. + * + * pup itself does not fail on a missing or broken .puprc: it reads either as empty, bumps nothing + * and exits 0. So the file is checked here, before pup runs, and a release branch is never pushed + * with the version left as it was. + * + * @param {string|null} text The contents of .puprc, or null when there is none. + * + * @throws {Error} When there is no .puprc, it is not JSON, or it declares no version files. + * + * @returns {{tbdDirs: string[]|null}} The directories the TBD check declares, or null when it + * declares none and pup falls back to its default. + */ +function readPuprc(text) { + if (text === null) { + throw new Error( + 'No .puprc in the repository. The version files to bump come from its paths.versions, so ' + + "the action needs one; see the action's README. Nothing was changed." + ) + } + + let config + try { + config = JSON.parse(text) + } catch (error) { + throw new Error( + `.puprc is not valid JSON (${error.message}). pup would read it as empty and bump nothing. ` + + 'Nothing was changed.' + ) + } + + const versions = config?.paths?.versions + if (!Array.isArray(versions) || versions.length === 0) { + throw new Error( + '.puprc has no paths.versions, so pup has no version to bump. List each file and the regex ' + + "that finds its version; see the action's README. Nothing was changed." + ) + } + + const dirs = config?.checks?.tbd?.dirs + + return { tbdDirs: Array.isArray(dirs) ? dirs : null } +} + +/** + * Whether package.json configures the changelogger. Without that section the changelogger falls + * back to its defaults, which write a keepachangelog `changelog.md` with semver versioning. + * + * @param {string|null} text The contents of package.json, or null when there is none. + * + * @returns {boolean} True when it has a `changelogger` section. + */ +function hasChangeloggerConfig(text) { + if (text === null) { + return false + } + + try { + return Boolean(JSON.parse(text)?.changelogger) + } catch { + return false + } +} + +/** + * Where a pup release's phar is published. + * + * @param {string} version A pup release number, already validated. + * + * @returns {string} The download URL. + */ +function pupDownloadUrl(version) { + return `https://github.com/stellarwp/pup/releases/download/${version}/pup.phar` +} + +/** + * The pup commands that prepare a version, in the order they run. + * + * @param {string} phar Path to the downloaded pup.phar. + * @param {string} version The version to prepare, already validated. + * + * @returns {{command: string, args: string[]}[]} The version bump, then the TBD replacement. + */ +function pupCommands(phar, version) { + return [ + { command: 'php', args: [phar, 'replace-version', version] }, + { command: 'php', args: [phar, 'replace-tbd', version] }, + ] +} + +/** + * The basic credential git sends for a token, as actions/checkout builds it. + * + * @param {string} token The token. + * + * @returns {string} base64 of `x-access-token:`. + */ +function basicCredential(token) { + return Buffer.from(`x-access-token:${token}`).toString('base64') +} + +/** + * The environment that hands git the token for one push. + * + * git reads GIT_CONFIG_COUNT and the numbered KEY/VALUE pairs as extra config for that one + * process. The token therefore never sits on a command line, which the log echoes, and is never + * written to .git/config, where a later step could read it. That is what lets a caller check out + * with `persist-credentials: false`. + * + * @param {string} token The token, or empty to leave authentication to the checkout. + * @param {string} serverUrl The GitHub server, e.g. https://github.com. + * + * @returns {object} The variables to add to git's environment; none without a token. + */ +function pushEnv(token, serverUrl) { + if (!token) { + return {} + } + + return { + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: `http.${serverUrl.replace(/\/+$/, '')}/.extraheader`, + GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${basicCredential(token)}`, + } +} + +/** + * The message of the commit the action pushes. + * + * @param {string} ref The release branch. + * @param {string} version The version prepared. + * + * @returns {string} The commit message. + */ +function commitMessage(ref, version) { + return `Prepare ${ref} (${version})` +} + +/** + * Reads a file of the checkout. + * + * @param {string} cwd The checkout. + * @param {string} name The file, relative to it. + * + * @throws {Error} When it exists but cannot be read. + * + * @returns {string|null} The contents, or null when the file does not exist. + */ +function readCheckoutFile(cwd, name) { + try { + return fs.readFileSync(path.join(cwd, name), 'utf8') + } catch (error) { + if (error.code === 'ENOENT') { + return null + } + + throw error + } +} + +/** + * 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. + * + * Every check runs before anything is written, so a wrong version, date or ref, a checkout that is + * not on the release branch, or a repository without a usable .puprc, fails with the branch + * untouched. + * + * @param {object} core The @actions/core toolkit, for the log and the outputs. + * @param {object} exec The @actions/exec toolkit. + * @param {object} env The environment the action manifest put its inputs in. + * @param {string} cwd The checkout, the working directory by default. + * + * @throws {Error} When an input is wrong, .puprc is unusable, or the download or pup fails. + * + * @returns {Promise} Resolves once pup has run and the outputs are set. + */ +async function runPrepare({ core, exec, env, cwd = process.cwd() }) { + const version = validateVersion(env.INPUT_VERSION) + const date = resolveDate(env.INPUT_DATE) + const ref = validateRef(env.INPUT_REF) + const pupVersion = validatePupVersion(env.INPUT_PUP_VERSION) + + const refCheck = await exec.getExecOutput('git', ['check-ref-format', `refs/heads/${ref}`], { + cwd, + ignoreReturnCode: true, + silent: true, + }) + if (refCheck.exitCode !== 0) { + throw new Error(`'${ref}' is not a valid branch name. Nothing was changed.`) + } + + // The push writes HEAD to refs/heads/. From any other branch that the release branch is an + // ancestor of, git would accept it as a fast-forward and carry that branch's commits into the + // release, so the checkout has to be on the release branch itself. + const head = await exec.getExecOutput('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], { + cwd, + ignoreReturnCode: true, + silent: true, + }) + const branch = head.exitCode === 0 ? head.stdout.trim() : '' + if (branch !== ref) { + throw new Error( + `The checkout is on ${branch ? `'${branch}'` : 'a detached commit'}, not on '${ref}'. Check ` + + 'out the release branch before this action, e.g. actions/checkout with `ref`. Nothing was ' + + 'changed.' + ) + } + + const { tbdDirs } = readPuprc(readCheckoutFile(cwd, '.puprc')) + + if (!tbdDirs) { + core.warning( + '.puprc declares no checks.tbd.dirs, so pup replaces TBDs under src/ only, its default. ' + + 'Declare the directories that hold @since TBD tags to cover the rest.' + ) + } + + if (!hasChangeloggerConfig(readCheckoutFile(cwd, 'package.json'))) { + core.warning( + 'package.json has no changelogger section, so the changelog step uses the changelogger ' + + 'defaults: a keepachangelog changelog.md with semver versioning.' + ) + } + + const phar = path.join(env.RUNNER_TEMP || os.tmpdir(), `pup-${pupVersion}.phar`) + + await runOrFail( + exec, + 'curl', + ['-fsSL', '--retry', '3', '-o', phar, pupDownloadUrl(pupVersion)], + {}, + `Downloading pup ${pupVersion}` + ) + + for (const { command, args } of pupCommands(phar, version)) { + await runOrFail(exec, command, args, { cwd }, `pup ${args[1]}`) + } + + core.setOutput('version', version) + core.setOutput('date', date) + core.setOutput('ref', ref) +} + +/** + * Commits whatever the preparation changed and pushes it to the release branch. + * + * When nothing changed, which is what re-preparing a version that is already on the branch finds, + * it neither commits nor pushes, and succeeds. + * + * @param {object} core The @actions/core toolkit, for the log, the outputs and the summary. + * @param {object} exec The @actions/exec toolkit. + * @param {object} env The environment the action manifest put its inputs in. + * @param {string} cwd The checkout, the working directory by default. + * + * @throws {Error} When a git command fails, the push included. + * + * @returns {Promise} Resolves once the commit is pushed, or found unnecessary. + */ +async function runCommit({ core, exec, env, cwd = process.cwd() }) { + const ref = validateRef(env.INPUT_REF) + const version = validateVersion(env.INPUT_VERSION) + const message = commitMessage(ref, version) + const token = env.INPUT_TOKEN || '' + + if (token) { + core.setSecret(token) + core.setSecret(basicCredential(token)) + } + + await runOrFail(exec, 'git', ['add', '--all'], { cwd }, 'Staging the changes') + + const staged = await runOrFail( + exec, + 'git', + ['diff', '--cached', '--name-only'], + { cwd, silent: true }, + 'Listing the changes' + ) + const files = staged.split('\n').filter(Boolean) + + if (files.length === 0) { + core.info(`Nothing changed: ${ref} already holds ${version}. Nothing was committed or pushed.`) + core.setOutput('commit', '') + core.setOutput('files', '') + await core.summary + .addRaw(`Nothing to commit: \`${ref}\` already holds ${version}.`, true) + .write() + + return + } + + await runOrFail( + exec, + 'git', + [ + '-c', + `user.name=${COMMIT_AUTHOR.name}`, + '-c', + `user.email=${COMMIT_AUTHOR.email}`, + 'commit', + '--quiet', + '-m', + message, + ], + { cwd }, + 'Committing the changes' + ) + + const commit = ( + await runOrFail(exec, 'git', ['rev-parse', 'HEAD'], { cwd, silent: true }, 'Reading the commit') + ).trim() + + await runOrFail( + exec, + 'git', + ['push', 'origin', `HEAD:refs/heads/${ref}`], + { cwd, env: { ...env, ...pushEnv(token, env.GITHUB_SERVER_URL || 'https://github.com') } }, + `Pushing to ${ref}` + ) + + core.info(`Pushed ${commit} to ${ref}.`) + core.setOutput('commit', commit) + core.setOutput('files', files.join('\n')) + // A Markdown list rather than addList, whose HTML list would show the backticks literally. + await core.summary + .addRaw(`Pushed \`${commit.slice(0, 7)}\` "${message}" to \`${ref}\`:\n\n`) + .addRaw(files.map((file) => `- \`${file}\``).join('\n'), true) + .write() +} + +module.exports = { + runPrepare, + runCommit, + validateVersion, + resolveDate, + validateRef, + validatePupVersion, + readPuprc, + hasChangeloggerConfig, + pupDownloadUrl, + pupCommands, + basicCredential, + pushEnv, + commitMessage, + VERSION_PATTERN, +} diff --git a/package.json b/package.json index 7527e5b..92b7070 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ }, "devDependencies": { "@actions/core": "^3.0.1", + "@actions/exec": "^3.0.0", "@actions/github": "^9.1.1" } } diff --git a/tests/actions/release-prep/release-prep.test.js b/tests/actions/release-prep/release-prep.test.js new file mode 100644 index 0000000..57905a3 --- /dev/null +++ b/tests/actions/release-prep/release-prep.test.js @@ -0,0 +1,619 @@ +'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 { + runPrepare, + runCommit, + validateVersion, + resolveDate, + validateRef, + validatePupVersion, + readPuprc, + hasChangeloggerConfig, + pupDownloadUrl, + pupCommands, + basicCredential, + pushEnv, + commitMessage, +} = require('../../../.github/actions/release-prep/release-prep.js') +const { coreWithOutputs, recordingExec } = require('../../support/actions.js') + +/** A .puprc that bumps the plugin header and replaces TBDs under src/. */ +const PUPRC = { + paths: { versions: [{ file: 'plugin.php', regex: '(Version: )(.+)' }] }, + checks: { tbd: { dirs: ['src'] } }, +} + +/** A package.json with a changelogger config, as a repository using the stellarwp format has. */ +const PACKAGE_JSON = { changelogger: { versioning: 'stellarwp', changesDir: 'changelog' } } + +/** Today in UTC, the way the action resolves an empty or `today` date. */ +const TODAY = new Date().toISOString().slice(0, 10) + +/** + * A temporary directory holding the given files, standing in for the checked-out repository. + * + * @param {object} files Each relative path mapped to its contents. An object is written as JSON. + * + * @returns {string} The directory. + */ +function checkoutWith(files = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'release-prep-')) + + for (const [name, contents] of Object.entries(files)) { + const text = typeof contents === 'string' ? contents : JSON.stringify(contents) + fs.writeFileSync(path.join(dir, name), text) + } + + return dir +} + +/** + * The environment the manifest hands the prepare step, over a valid set of inputs. + * + * @param {object} overrides Inputs to change. + * + * @returns {object} The environment. + */ +function prepareEnv(overrides = {}) { + return { + INPUT_REF: 'release/4.17.0', + INPUT_VERSION: '4.17.0', + INPUT_DATE: '', + INPUT_PUP_VERSION: '2.0.0', + RUNNER_TEMP: '/runner/temp', + ...overrides, + } +} + +/** + * Rules under which every command the prepare step runs succeeds. + * + * @param {object[]} before Rules that take precedence, to make one command fail. + * + * @returns {object[]} Rules for recordingExec. + */ +function allSucceed(before = []) { + return [ + ...before, + { command: 'git', args: /^check-ref-format / }, + { command: 'git', args: /^symbolic-ref /, stdout: 'release/4.17.0\n' }, + { command: 'curl' }, + { command: 'php', stdout: 'ok' }, + ] +} + +/** + * Each call as `command arg arg…`, for asserting the order the action worked in. + * + * @param {object[]} calls The calls recordingExec recorded. + * + * @returns {string[]} One line per call. + */ +function sequence(calls) { + return calls.map((call) => [call.command, ...call.args].join(' ')) +} + +/** + * 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) { + const { execFileSync } = require('node:child_process') + + return execFileSync('git', args, { cwd: dir, encoding: 'utf8' }).trim() +} + +/** + * A working copy of a repository whose `origin` is a local bare repository holding `branch`. + * + * The commit step then runs real git against it: a push goes to the bare repository, so a test can + * read back what landed on the branch. + * + * @param {string} branch The release branch the working copy has checked out. + * + * @returns {{work: string, remote: string}} The two directories. + */ +function repositoryOn(branch) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'release-prep-git-')) + const remote = path.join(root, 'remote.git') + const work = path.join(root, 'work') + + git(root, 'init', '--quiet', '--bare', remote) + git(root, 'init', '--quiet', '-b', branch, work) + git(work, 'config', 'user.name', 'Test') + git(work, 'config', 'user.email', 'test@example.com') + fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.16.0\n') + git(work, 'add', '-A') + git(work, 'commit', '--quiet', '-m', 'Initial') + git(work, 'remote', 'add', 'origin', remote) + git(work, 'push', '--quiet', 'origin', `HEAD:refs/heads/${branch}`) + + return { work, remote } +} + +/** + * 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. + * + * @param {object} overrides Inputs to change. + * + * @returns {object} The environment, over the real one so that git is on the PATH. + */ +function commitEnv(overrides = {}) { + return { + ...process.env, + INPUT_REF: 'release/4.17.0', + INPUT_VERSION: '4.17.0', + INPUT_TOKEN: 'ghs_notarealtoken123', + GITHUB_SERVER_URL: 'https://github.com', + ...overrides, + } +} + +describe('release-prep', () => { + describe('validateVersion', () => { + it('accepts three or four numeric parts', () => { + assert.equal(validateVersion('4.17.0'), '4.17.0') + assert.equal(validateVersion('4.17.0.1'), '4.17.0.1') + }) + + it('reads the version without surrounding whitespace', () => { + assert.equal(validateVersion(' 4.17.0\n'), '4.17.0') + }) + + it('refuses an empty version, saying the run was dispatched without one', () => { + // A release dispatcher that has no version for the release sends an empty one. + for (const empty of ['', ' ', undefined]) { + assert.throws(() => validateVersion(empty), /No version was given.*Nothing was changed/s) + } + }) + + it('refuses a pre-release, which the stellarwp changelog versioning rejects', () => { + assert.throws( + () => validateVersion('4.17.0-beta.1'), + /'4\.17\.0-beta\.1' is not a version this action can prepare.*pre-release/s + ) + }) + + it('refuses anything but three or four numeric parts', () => { + // 2.1..21 matters most: the changelogger reads the empty part as 0 and would accept it. + for (const version of ['4.17', '4.17.0.1.2', '2.1..21', 'v4.17.0', '4.17.x', '4.17.0.']) { + assert.throws(() => validateVersion(version), /not a version this action can prepare/) + } + }) + }) + + describe('resolveDate', () => { + const now = new Date('2026-10-06T23:30:00-05:00') + + it('reads an empty date and `today` as today in UTC', () => { + // 23:30 in UTC-5 is already the 7th in UTC. + assert.equal(resolveDate('', now), '2026-10-07') + assert.equal(resolveDate(undefined, now), '2026-10-07') + assert.equal(resolveDate('today', now), '2026-10-07') + assert.equal(resolveDate(' Today ', now), '2026-10-07') + }) + + it('passes a YYYY-MM-DD date through', () => { + assert.equal(resolveDate('2026-10-06', now), '2026-10-06') + }) + + it('refuses anything that is not a real YYYY-MM-DD date', () => { + for (const date of ['ontem', 'yesterday', '06/10/2026', '2026-10-6', '2026-02-30', '2026-13-45']) { + assert.throws(() => resolveDate(date, now), /is not a date.*Nothing was changed/s) + } + }) + }) + + describe('validateRef', () => { + it('accepts a branch name', () => { + assert.equal(validateRef('release/4.17.0'), 'release/4.17.0') + }) + + it('refuses an empty ref', () => { + assert.throws(() => validateRef(''), /No ref was given/) + }) + + it('refuses a full ref, naming the branch to pass instead', () => { + assert.throws( + () => validateRef('refs/heads/release/4.17.0'), + /pass the branch name, e\.g\. 'release\/4\.17\.0'/ + ) + }) + }) + + describe('validatePupVersion', () => { + it('accepts a release number, which goes into the download URL', () => { + assert.equal(validatePupVersion('2.0.0'), '2.0.0') + }) + + it('refuses anything else', () => { + for (const version of ['', 'latest', '2.0.0/../../x', '2.0']) { + assert.throws(() => validatePupVersion(version), /pup-version/) + } + }) + }) + + describe('readPuprc', () => { + it('returns the TBD directories the file declares', () => { + assert.deepEqual(readPuprc(JSON.stringify(PUPRC)), { tbdDirs: ['src'] }) + }) + + it('returns no TBD directories when the file declares none', () => { + const { checks, ...withoutChecks } = PUPRC + + assert.deepEqual(readPuprc(JSON.stringify(withoutChecks)), { tbdDirs: null }) + }) + + it('refuses a missing file, which pup would treat as an empty one', () => { + assert.throws(() => readPuprc(null), /No \.puprc.*Nothing was changed/s) + }) + + it('refuses a file that is not JSON, which pup would also treat as empty', () => { + assert.throws(() => readPuprc('{ "paths": '), /\.puprc is not valid JSON/) + }) + + it('refuses a file with no version files to bump', () => { + assert.throws(() => readPuprc('{}'), /no paths\.versions/) + assert.throws(() => readPuprc('{"paths":{"versions":[]}}'), /no paths\.versions/) + }) + }) + + describe('hasChangeloggerConfig', () => { + it('is true only for a package.json with a changelogger section', () => { + assert.equal(hasChangeloggerConfig(JSON.stringify(PACKAGE_JSON)), true) + assert.equal(hasChangeloggerConfig('{"name":"x"}'), false) + assert.equal(hasChangeloggerConfig(null), false) + assert.equal(hasChangeloggerConfig('not json'), false) + }) + }) + + describe('the pup commands', () => { + it('downloads the pinned release', () => { + assert.equal( + pupDownloadUrl('2.0.0'), + 'https://github.com/stellarwp/pup/releases/download/2.0.0/pup.phar' + ) + }) + + it('bumps the version files, then replaces the TBDs', () => { + assert.deepEqual(pupCommands('/tmp/pup.phar', '4.17.0'), [ + { command: 'php', args: ['/tmp/pup.phar', 'replace-version', '4.17.0'] }, + { command: 'php', args: ['/tmp/pup.phar', 'replace-tbd', '4.17.0'] }, + ]) + }) + }) + + describe('the push credential', () => { + it('is the basic credential git uses for a token', () => { + const credential = basicCredential('ghs_abc') + + assert.equal(Buffer.from(credential, 'base64').toString(), 'x-access-token:ghs_abc') + }) + + it('reaches git through its environment, scoped to the server', () => { + assert.deepEqual(pushEnv('ghs_abc', 'https://github.com'), { + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader', + GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${basicCredential('ghs_abc')}`, + }) + }) + + it('adds nothing without a token, leaving the checkout credentials to git', () => { + assert.deepEqual(pushEnv('', 'https://github.com'), {}) + }) + }) + + describe('commitMessage', () => { + it('names the branch and the version', () => { + assert.equal(commitMessage('release/4.17.0', '4.17.0'), 'Prepare release/4.17.0 (4.17.0)') + }) + }) + + describe('runPrepare', () => { + it('checks the ref, downloads pup, bumps the versions and then replaces the TBDs', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await runPrepare({ core, exec, env: prepareEnv(), cwd }) + + const phar = '/runner/temp/pup-2.0.0.phar' + assert.deepEqual(sequence(calls), [ + 'git check-ref-format refs/heads/release/4.17.0', + 'git symbolic-ref --quiet --short HEAD', + `curl -fsSL --retry 3 -o ${phar} https://github.com/stellarwp/pup/releases/download/2.0.0/pup.phar`, + `php ${phar} replace-version 4.17.0`, + `php ${phar} replace-tbd 4.17.0`, + ]) + assert.ok( + calls.filter((call) => call.command === 'php').every((call) => call.options.cwd === cwd), + 'pup runs in the checkout' + ) + assert.deepEqual(outputs(), { version: '4.17.0', date: TODAY, ref: 'release/4.17.0' }) + }) + + it('hands the changelog step a date it can write, never `today`', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await runPrepare({ core, exec, env: prepareEnv({ INPUT_DATE: 'today' }), cwd }) + assert.equal(outputs().date, TODAY) + }) + + it('passes a given date through', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await runPrepare({ core, exec, env: prepareEnv({ INPUT_DATE: '2026-10-06' }), cwd }) + assert.equal(outputs().date, '2026-10-06') + }) + + it('stops before running anything when the version, the date or pup-version is wrong', async (t) => { + const cases = [ + [{ INPUT_VERSION: '' }, /No version was given/], + [{ INPUT_VERSION: '4.17.0-beta.1' }, /not a version this action can prepare/], + [{ INPUT_VERSION: '2.1..21' }, /not a version this action can prepare/], + [{ INPUT_DATE: 'ontem' }, /is not a date/], + [{ INPUT_REF: '' }, /No ref was given/], + [{ INPUT_PUP_VERSION: 'latest' }, /pup-version/], + ] + + for (const [overrides, message] of cases) { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects(runPrepare({ core, exec, env: prepareEnv(overrides), cwd }), message) + assert.deepEqual(calls, [], `nothing ran for ${JSON.stringify(overrides)}`) + assert.deepEqual(outputs(), {}) + } + }) + + it('stops before downloading pup when the ref is not a valid branch name', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([{ command: 'git', args: /^check-ref-format /, exitCode: 1 }]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv({ INPUT_REF: 'release/..x' }), cwd }), + /'release\/\.\.x' is not a valid branch name/ + ) + assert.deepEqual(sequence(calls), ['git check-ref-format refs/heads/release/..x']) + }) + + it('stops before downloading pup when the checkout is on another branch', async (t) => { + // The push writes HEAD to refs/heads/. From another branch that the release branch is an + // ancestor of, it would be accepted as a fast-forward and carry that branch's commits along. + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([{ command: 'git', args: /^symbolic-ref /, stdout: 'main\n' }]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv(), cwd }), + /The checkout is on 'main', not on 'release\/4\.17\.0'.*Nothing was changed/s + ) + assert.ok(!calls.some((call) => call.command === 'curl' || call.command === 'php')) + }) + + it('stops before downloading pup when the checkout is on no branch at all', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([{ command: 'git', args: /^symbolic-ref /, exitCode: 1 }]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv(), cwd }), + /The checkout is on a detached commit, not on 'release\/4\.17\.0'/ + ) + assert.ok(!calls.some((call) => call.command === 'curl' || call.command === 'php')) + }) + + it('stops before running pup when the repository has no usable .puprc', async (t) => { + const cases = [ + [{}, /No \.puprc/], + [{ '.puprc': '{ "paths": ' }, /\.puprc is not valid JSON/], + [{ '.puprc': {} }, /no paths\.versions/], + ] + + for (const [files, message] of cases) { + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ 'package.json': PACKAGE_JSON, ...files }) + + await assert.rejects(runPrepare({ core, exec, env: prepareEnv(), cwd }), message) + assert.ok( + !calls.some((call) => call.command === 'curl' || call.command === 'php'), + `pup never ran, got ${JSON.stringify(sequence(calls))}` + ) + } + }) + + it('warns when the .puprc leaves the TBD directories to pup', async (t) => { + const { core, logged } = await coreWithOutputs(t) + const { exec } = recordingExec(allSucceed()) + const { checks, ...withoutChecks } = PUPRC + const cwd = checkoutWith({ '.puprc': withoutChecks, 'package.json': PACKAGE_JSON }) + + await runPrepare({ core, exec, env: prepareEnv(), cwd }) + + assert.match(logged(), /::warning::.*checks\.tbd\.dirs.*src\//) + }) + + it('warns when package.json has no changelogger section', async (t) => { + // The changelogger falls back to its defaults without one, and writes a changelog.md. + const { core, logged } = await coreWithOutputs(t) + const { exec } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': { name: 'x' } }) + + await runPrepare({ core, exec, env: prepareEnv(), cwd }) + + assert.match(logged(), /::warning::.*changelogger/) + }) + + it('fails with pup output when pup fails, and stops there', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([ + { + command: 'php', + args: /replace-version/, + exitCode: 1, + stdout: 'No version files found in .puprc paths.versions.', + }, + ]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv(), cwd }), + /pup replace-version failed.*No version files found/s + ) + assert.ok(!sequence(calls).some((call) => call.includes('replace-tbd')), 'replace-tbd never ran') + assert.deepEqual(outputs(), {}) + }) + + it('fails when the pup download fails', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec } = recordingExec( + allSucceed([{ command: 'curl', exitCode: 22, stderr: 'The requested URL returned error: 404' }]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv(), cwd }), + /Downloading pup 2\.0\.0 failed.*404/s + ) + }) + }) + + describe('runCommit', () => { + it('commits every change and pushes it to the release branch', async (t) => { + const { core, outputs, summary } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + const { work, remote } = repositoryOn('release/4.17.0') + fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.17.0\n') + fs.writeFileSync(path.join(work, 'changelog.txt'), '= 4.17.0 =\n') + + await runCommit({ core, exec, env: commitEnv(), cwd: work }) + + const pushed = git(remote, 'rev-parse', 'refs/heads/release/4.17.0') + assert.equal(pushed, git(work, 'rev-parse', 'HEAD')) + assert.equal(git(remote, 'log', '-1', '--format=%s', pushed), 'Prepare release/4.17.0 (4.17.0)') + assert.equal( + git(remote, 'log', '-1', '--format=%an <%ae>', pushed), + 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>' + ) + assert.deepEqual(outputs(), { commit: pushed, files: 'changelog.txt\nplugin.php' }) + assert.match(summary(), /Prepare release\/4\.17\.0 \(4\.17\.0\)/) + assert.match(summary(), /^- `changelog\.txt`\n- `plugin\.php`$/m) + }) + + it('never puts the token on a command line, in the log or in the git config', async (t) => { + const { core, logged } = await coreWithOutputs(t) + const { exec, calls, printed } = await recordedRealExec() + const { work } = repositoryOn('release/4.17.0') + fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.17.0\n') + const token = commitEnv().INPUT_TOKEN + const credential = basicCredential(token) + + await runCommit({ core, exec, env: commitEnv(), cwd: work }) + + assert.match(printed(), /\[command\].*git push origin HEAD:refs\/heads\/release\/4\.17\.0/) + const log = `${logged()}\n${printed()}` + assert.match(log, new RegExp(`::add-mask::${token}`)) + assert.match(log, new RegExp(`::add-mask::${credential}`)) + + const unmasked = log.split('\n').filter((line) => !line.startsWith('::add-mask::')) + assert.ok(!unmasked.some((line) => line.includes(token) || line.includes(credential))) + assert.ok(!calls.some((call) => call.args.join(' ').includes(credential))) + assert.ok(!fs.readFileSync(path.join(work, '.git', 'config'), 'utf8').includes(credential)) + + const push = calls.find((call) => call.args[0] === 'push') + assert.deepEqual(push.args, ['push', 'origin', 'HEAD:refs/heads/release/4.17.0']) + assert.equal(push.options.env.GIT_CONFIG_VALUE_0, `AUTHORIZATION: basic ${credential}`) + }) + + it('neither commits nor pushes when nothing changed, and still succeeds', async (t) => { + // A retry of the same version finds the branch already prepared. + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = await recordedRealExec() + const { work, remote } = repositoryOn('release/4.17.0') + const before = git(remote, 'rev-parse', 'refs/heads/release/4.17.0') + + await runCommit({ core, exec, env: commitEnv(), cwd: work }) + + assert.equal(git(remote, 'rev-parse', 'refs/heads/release/4.17.0'), before) + assert.ok(!calls.some((call) => call.args.includes('commit') || call.args.includes('push'))) + assert.deepEqual(outputs(), { commit: '', files: '' }) + }) + + it('fails with git output when the push is refused', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + const { work, remote } = repositoryOn('release/4.17.0') + + // Someone else pushed to the branch after the checkout, so the push is not a fast-forward. + const other = path.join(path.dirname(remote), 'other') + git(path.dirname(remote), 'clone', '--quiet', '-b', 'release/4.17.0', remote, other) + git(other, 'config', 'user.name', 'Other') + git(other, 'config', 'user.email', 'other@example.com') + git(other, 'commit', '--quiet', '--allow-empty', '-m', 'Elsewhere') + git(other, 'push', '--quiet', 'origin', 'HEAD:refs/heads/release/4.17.0') + + fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.17.0\n') + + await assert.rejects( + runCommit({ core, exec, env: commitEnv(), cwd: work }), + /Pushing to release\/4\.17\.0 failed.*rejected/s + ) + }) + }) +}) From 496f901668facc6e06aa3647fd4189584461fae1 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Thu, 8 Oct 2026 14:48:51 -0300 Subject: [PATCH 04/27] Add the release-prep action manifest and its README --- .github/actions/release-prep/README.md | 181 ++++++++++++++++++++++++ .github/actions/release-prep/action.yml | 98 +++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 .github/actions/release-prep/README.md create mode 100644 .github/actions/release-prep/action.yml diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md new file mode 100644 index 0000000..89f1563 --- /dev/null +++ b/.github/actions/release-prep/README.md @@ -0,0 +1,181 @@ +# release-prep + +Prepares a release branch. It bumps the version, replaces the `@since TBD` placeholders and writes the +changelog, then commits that and pushes it to the branch: + +```yaml +- uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + +- uses: stellarwp/plugin-toolbox/.github/actions/release-prep@v1 + with: + ref: ${{ inputs.ref }} + version: ${{ inputs.version }} +``` + +It opens no pull request. The commit lands on the release branch itself, and is reviewed wherever the +release is reviewed, typically the pull request from the release branch to the main branch. + +[`templates/workflows/release-prep.yml`](../../../templates/workflows/release-prep.yml) is a complete +workflow around it, ready to copy into a plugin repository. + +## What it changes + +Three tools do the work, in this order, from the root of the checkout: + +1. `pup replace-version ` rewrites the version in every file the `.puprc` lists under + `paths.versions`. +2. `pup replace-tbd ` replaces the TBD placeholders (`@since TBD`, `@deprecated TBD`, + `_deprecated_function( __METHOD__, 'TBD' )` and the like) in the directories the `.puprc` lists under + `checks.tbd.dirs`. +3. The [changelogger action](https://github.com/stellarwp/changelogger) writes the pending entries in + `changelog/` into the changelog files and deletes them, with the version and the date. + +Then everything the working tree changed is committed as `Prepare ()` by +`github-actions[bot]` and pushed to `ref`. Everything means everything, so run the action on a clean +checkout, and put any step that writes tracked files after it. + +pup is downloaded from its GitHub release, pinned by `pup-version`, into the runner's temp directory, +so it never ends up in the commit. It runs on the runner's PHP. + +## What the repository needs + +A `.puprc` that lists the version files, and the directories that hold the TBD placeholders: + +```json +{ + "paths": { + "versions": [ + { "file": "my-plugin.php", "regex": "(Version: )(.+)" }, + { "file": "my-plugin.php", "regex": "(define\\( 'MY_PLUGIN_VERSION', ')([^']+)" }, + { "file": "readme.txt", "regex": "(Stable tag: )(.+)" } + ] + }, + "checks": { + "tbd": { + "dirs": ["src", "includes"] + } + } +} +``` + +A `changelogger` section in `package.json`, and the pending entries in its `changesDir`: + +```json +{ + "changelogger": { + "versioning": "stellarwp", + "changesDir": "changelog", + "files": [{ "path": "readme.txt", "strategy": "stellarwp-readme" }] + } +} +``` + +The action checks the first before running anything, because pup does not: a missing `.puprc`, one that +is not JSON, and one with no `paths.versions` all make pup change nothing and exit 0, and the branch +would be pushed with the old version. Each of them fails the job here with the branch untouched. + +Two gaps are warnings rather than failures, since the job can still do something useful: + +- No `checks.tbd.dirs`: pup falls back to its default and replaces TBDs under `src/` only. +- No `changelogger` section: the changelogger falls back to its defaults and writes a keepachangelog + `changelog.md` with semver versioning. + +A writing strategy given as a `.js` file is loaded from the checkout, so it has to be there before the +action runs. One written in TypeScript has to be built in an earlier step. + +## Versions it refuses + +The version has to be three or four numeric parts, such as `4.17.0` or `4.17.0.1`. That is what the +changelogger's `stellarwp` versioning accepts, so a version the changelog step would fail on is refused +before pup has touched anything. + +- **An empty version is refused.** A tool that dispatches the workflow for a release with no version set + sends an empty one. The job fails with "No version was given", and the release needs a version before + the step is run again. +- **A pre-release is refused.** `4.17.0-beta.1` is rejected by the `stellarwp` versioning, so the action + cannot prepare one, even though it is a valid tag name. + +Each refusal happens before anything is changed, as does a `date` that is not `YYYY-MM-DD`, a `ref` +that is not a valid branch name, and a checkout that is not on `ref`. + +## Inputs + +| Input | Required | Default | What it does | +|---|---|---|---| +| `ref` | yes | | The release branch to push to, e.g. `release/4.17.0`. A branch name, not `refs/heads/…`. The checkout has to be on it; see [Pushing](#pushing) | +| `version` | yes | | The version to prepare, e.g. `4.17.0`. See [Versions it refuses](#versions-it-refuses) | +| `date` | no | today, in UTC | The changelog date as `YYYY-MM-DD`. Empty or `today` writes today | +| `token` | no | `github.token` | The token the commit is pushed with. See [Pushing](#pushing) | +| `pup-version` | no | `2.0.0` | The pup release to run. It needs `replace-version` and `replace-tbd`, which came with 2.0.0 | + +## Outputs + +| Output | Example | What it is | +|---|---|---| +| `version` | `4.17.0` | The version prepared | +| `date` | `2026-10-08` | The date written in the changelog | +| `commit` | `3f2a…` | The commit pushed to the branch. Empty when nothing changed | +| `files` | `my-plugin.php` | The files the commit changed, one per line. Empty when nothing changed | + +## Running it again + +Preparing a version the branch already holds changes nothing: the versions match, no TBDs are left and +`changelog/` is empty. The action then commits and pushes nothing, and succeeds, so a retry of a +finished preparation is harmless. + +## The date + +The changelogger action writes the date exactly as it is given. `today` would land in the changelog as +the word, and an empty value as an empty date. So the action resolves both to today's date, in UTC, +before the changelog step runs, and that is the date the `date` output reports. + +## Pushing + +The job needs `contents: write`: + +```yaml +jobs: + prepare: + permissions: + contents: write +``` + +The action pushes with `token` itself, to `HEAD:refs/heads/`, so the checkout can and should use +`persist-credentials: false`. The token reaches git through the environment of that one push, never +on its command line, which the log echoes, and never in `.git/config`, where a later step could read it. +It is masked in the log, together with the encoded credential built from it. + +The push is a fast-forward. If the branch moved after the checkout, the push is refused and the job +fails with git's output, rather than overwriting what landed. + +The checkout has to be on `ref` itself, and the action checks that before it changes anything. Pushing +from another branch would also be a fast-forward whenever the release branch is an ancestor of it, +for instance a run started from `main` whose checkout was given no `ref`, and the release would pick +up every commit of that branch. A detached checkout, of a tag or a commit, is refused for the same +reason. + +Pass a PAT or app token as `token` when a ruleset on the release branches blocks `github.token`, or when +a workflow in the repository has to run on the push. GitHub creates no workflow run for a push made +with `GITHUB_TOKEN`. + +## How it runs + +Two [actions/github-script](https://github.com/actions/github-script) steps run `release-prep.js`, with +the changelogger action between them. The first validates the inputs and the `.puprc`, then runs pup. +The second commits and pushes. github-script supplies the Node runtime and `exec`, so there is no +dependency to install and no bundle to build: the file in the repository is the file that runs. + +The script is loaded by path rather than inlined in the manifest, because github-script's `require` +resolves against the workspace of the repository being built, not this action's directory. The path +comes from `${{ github.action_path }}`. + +Inputs reach the script through `process.env`, never by being interpolated into it, so a version or a +branch name is data and cannot become part of the program. The commands run with their arguments as a +list, without a shell. + +`tests/actions/release-prep/release-prep.test.js` covers the rules above. The pup steps run against a +stand-in for `exec`; the commit and push run real git against a local repository. Run `npm install` +once, then `node --test`. diff --git a/.github/actions/release-prep/action.yml b/.github/actions/release-prep/action.yml new file mode 100644 index 0000000..68f4011 --- /dev/null +++ b/.github/actions/release-prep/action.yml @@ -0,0 +1,98 @@ +# Prepares a release branch: bumps the version, replaces the TBD placeholders, writes the changelog, +# then commits that and pushes it to the branch. It opens no pull request, so the change is reviewed +# wherever the release itself is reviewed. +# +# The checks and the git work live in release-prep.js, run by actions/github-script, so there is no +# dependency to install and nothing to build. The changelog is written by the changelogger's own +# action between the two script steps, rather than reimplemented here. +name: Release prep +description: Bumps the version, replaces TBDs and writes the changelog, then pushes it to the release branch + +inputs: + ref: + description: >- + The release branch to push the preparation to, e.g. `release/4.17.0`. A branch name, not a + ref. The action does not check it out: the caller's checkout has to be on it, and the action + refuses to run when it is not. + required: true + version: + description: >- + The version to prepare: three or four numeric parts, such as `4.17.0` or `4.17.0.1`. An empty + value and a pre-release such as `4.17.0-beta.1` are refused before anything is changed. + required: true + date: + description: >- + The release date for the changelog, as `YYYY-MM-DD`. Empty or `today` writes today's date in + UTC. + required: false + default: '' + token: + description: >- + Token the commit is pushed with. It needs `contents: write` on the repository, so the job needs + that permission too. The action pushes with it explicitly, so the checkout can use + `persist-credentials: false`. + required: false + default: ${{ github.token }} + pup-version: + description: 'The pup release that bumps the version and replaces the TBDs.' + required: false + default: '2.0.0' + +outputs: + version: + description: 'The version prepared.' + value: ${{ steps.prepare.outputs.version }} + date: + description: 'The date written in the changelog, as `YYYY-MM-DD`.' + value: ${{ steps.prepare.outputs.date }} + commit: + description: 'The commit pushed to the branch. Empty when nothing changed and nothing was pushed.' + value: ${{ steps.commit.outputs.commit }} + files: + description: 'The files the commit changed, one per line. Empty when nothing changed.' + value: ${{ steps.commit.outputs.files }} + +runs: + using: 'composite' + steps: + - name: Check the inputs, bump the version and replace the TBDs + id: prepare + 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_REF: ${{ inputs.ref }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_DATE: ${{ inputs.date }} + INPUT_PUP_VERSION: ${{ inputs.pup-version }} + with: + script: | + const { runPrepare } = require(`${process.env.ACTION_PATH}/release-prep.js`) + await runPrepare({ core, exec, env: process.env }) + + # The resolved date, never inputs.date: this action writes the date it is given as it is, so + # `today` would land in the changelog as the word and an empty value as an empty date. + - name: Write the changelog + uses: stellarwp/changelogger@v1 + with: + command: write + version: ${{ steps.prepare.outputs.version }} + date: ${{ steps.prepare.outputs.date }} + + - name: Commit the preparation and push it to the release branch + id: commit + uses: actions/github-script@v9 + env: + ACTION_PATH: ${{ github.action_path }} + # The values the prepare step validated, not the raw inputs. + INPUT_REF: ${{ steps.prepare.outputs.ref }} + INPUT_VERSION: ${{ steps.prepare.outputs.version }} + INPUT_TOKEN: ${{ inputs.token }} + with: + script: | + const { runCommit } = require(`${process.env.ACTION_PATH}/release-prep.js`) + await runCommit({ core, exec, env: process.env }) From 6d0e31019a4b0942a9b445080afbe9d7a2d711a1 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Thu, 8 Oct 2026 14:48:51 -0300 Subject: [PATCH 05/27] Add a release-prep caller to copy, and tick release-prep off the plan --- README.md | 3 +- templates/workflows/release-prep.yml | 63 ++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 templates/workflows/release-prep.yml diff --git a/README.md b/README.md index 972aa15..a7f2491 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,7 @@ Each phase ends with something people can actually use. - [ ] Port `setup-slic`, `prepare-matrix`, `setup-bun` from LearnDash. - [ ] 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 and pushed to the release branch. A caller to copy is in `templates/workflows/release-prep.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`. @@ -170,7 +171,7 @@ Each phase ends with something people can actually use. - [ ] `tests.yml`: slic suites via `prepare-matrix`, with LD's cheap/expensive split. Review TEC's paused [#51](https://github.com/the-events-calendar/actions/pull/51) first so that work isn't lost. - [ ] `changelog.yml`: the changelogger check. - [ ] `zip.yml`: reconcile TEC's and LD's versions. -- [ ] `release-prep.yml`: version bump, changelog, tested-up-to. +- [ ] `release-prep.yml`: a `workflow_call` wrapper for the `release-prep` action, plus the tested-up-to bump it does not do yet. - [ ] `release-tags.yml`: a `workflow_call` wrapper for the floating tag actions, so a repo gets a `uses:` instead of copying the workflow. The actions have to be referenced by full path, not `./`, because a relative path in a called workflow resolves against the caller. - [ ] Document each in `docs/continuous-integration.md` with its local command. diff --git a/templates/workflows/release-prep.yml b/templates/workflows/release-prep.yml new file mode 100644 index 0000000..e8298b6 --- /dev/null +++ b/templates/workflows/release-prep.yml @@ -0,0 +1,63 @@ +name: Release Prep + +# Prepares a release branch: bumps the version, replaces the @since TBD tags and writes the +# changelog, then commits that and pushes it to the branch. No pull request is opened, so the +# change is reviewed wherever the release is. +# +# A file to copy into .github/workflows/ of a plugin repository, not a workflow to call. The +# repository needs a .puprc and a changelogger config first; see the release-prep action's README. +# +# The inputs are the interface a release tool dispatches: `ref` and `version`, and an optional +# `date` for runs started by hand. Keep `date` optional: a tool that sends only `ref` and `version` +# refuses a workflow that requires more. +# +# Run it locally: you cannot, it pushes to a branch. Start it by hand with +# `gh workflow run release-prep.yml -f ref=release/4.17.0 -f version=4.17.0`. + +on: + workflow_dispatch: + inputs: + ref: + description: 'Release branch to prepare and push to, e.g. release/4.17.0' + required: true + type: string + version: + description: 'Version to write, e.g. 4.17.0' + required: true + type: string + date: + description: 'Changelog date (YYYY-MM-DD). Empty or "today" writes today, in UTC' + required: false + type: string + default: '' + +# Two preparations of the same branch would both commit on top of the same checkout, and the second +# push would be refused. One group per branch makes the second wait for the first. +concurrency: + group: ${{ github.workflow }}-${{ inputs.ref }} + +permissions: + contents: read + +jobs: + prepare: + # pup runs on PHP, which ubuntu-24.04 ships. Pinned so that stays true. + runs-on: ubuntu-24.04 + permissions: + contents: write # the action pushes the preparation to the branch + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + # The action pushes with the token it is given, so the checkout keeps no credentials in + # .git/config for a later step to find. + persist-credentials: false + + # A changelog writing strategy that has to be built first, or a toolchain the repository needs, + # is set up here, before the action. + + - uses: stellarwp/plugin-toolbox/.github/actions/release-prep@v1 + with: + ref: ${{ inputs.ref }} + version: ${{ inputs.version }} + date: ${{ inputs.date }} From 652b39ce18389c409d467ddc5c1f21989f69dc33 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Thu, 8 Oct 2026 15:18:31 -0300 Subject: [PATCH 06/27] Clear a credential the checkout persisted before pushing with the token --- .github/actions/release-prep/README.md | 5 +++ .github/actions/release-prep/release-prep.js | 15 +++++-- .../actions/release-prep/release-prep.test.js | 44 +++++++++++++++++-- 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md index 89f1563..b87259c 100644 --- a/.github/actions/release-prep/README.md +++ b/.github/actions/release-prep/README.md @@ -148,6 +148,11 @@ The action pushes with `token` itself, to `HEAD:refs/heads/`, so the checko on its command line, which the log echoes, and never in `.git/config`, where a later step could read it. It is masked in the log, together with the encoded credential built from it. +A checkout that keeps its credentials does not get in the way. actions/checkout stores its token under +the same `http./.extraheader` key, and git sends every value of that key, so the push would +carry two Authorization headers and GitHub would refuse it. The action clears that key for its push +before adding its own header, so only `token` is sent. + The push is a fast-forward. If the branch moved after the checkout, the push is refused and the job fails with git's output, rather than overwriting what landed. diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index 5ff9f98..56516d3 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -258,6 +258,11 @@ function basicCredential(token) { * written to .git/config, where a later step could read it. That is what lets a caller check out * with `persist-credentials: false`. * + * A checkout that does persist its credentials puts its own header under the same key, and git + * sends every value of it: two Authorization headers, which GitHub refuses with a 400. An empty + * value resets the key's list, so the first entry clears whatever the checkout left and only this + * token is sent. + * * @param {string} token The token, or empty to leave authentication to the checkout. * @param {string} serverUrl The GitHub server, e.g. https://github.com. * @@ -268,10 +273,14 @@ function pushEnv(token, serverUrl) { return {} } + const key = `http.${serverUrl.replace(/\/+$/, '')}/.extraheader` + return { - GIT_CONFIG_COUNT: '1', - GIT_CONFIG_KEY_0: `http.${serverUrl.replace(/\/+$/, '')}/.extraheader`, - GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${basicCredential(token)}`, + GIT_CONFIG_COUNT: '2', + GIT_CONFIG_KEY_0: key, + GIT_CONFIG_VALUE_0: '', + GIT_CONFIG_KEY_1: key, + GIT_CONFIG_VALUE_1: `AUTHORIZATION: basic ${basicCredential(token)}`, } } diff --git a/tests/actions/release-prep/release-prep.test.js b/tests/actions/release-prep/release-prep.test.js index 57905a3..4845d10 100644 --- a/tests/actions/release-prep/release-prep.test.js +++ b/tests/actions/release-prep/release-prep.test.js @@ -331,11 +331,14 @@ describe('release-prep', () => { assert.equal(Buffer.from(credential, 'base64').toString(), 'x-access-token:ghs_abc') }) - it('reaches git through its environment, scoped to the server', () => { + it('reaches git through its environment, scoped to the server, after clearing the key', () => { + // The empty first value resets the list, dropping a header the checkout persisted. assert.deepEqual(pushEnv('ghs_abc', 'https://github.com'), { - GIT_CONFIG_COUNT: '1', + GIT_CONFIG_COUNT: '2', GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader', - GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${basicCredential('ghs_abc')}`, + GIT_CONFIG_VALUE_0: '', + GIT_CONFIG_KEY_1: 'http.https://github.com/.extraheader', + GIT_CONFIG_VALUE_1: `AUTHORIZATION: basic ${basicCredential('ghs_abc')}`, }) }) @@ -578,7 +581,40 @@ describe('release-prep', () => { const push = calls.find((call) => call.args[0] === 'push') assert.deepEqual(push.args, ['push', 'origin', 'HEAD:refs/heads/release/4.17.0']) - assert.equal(push.options.env.GIT_CONFIG_VALUE_0, `AUTHORIZATION: basic ${credential}`) + assert.equal(push.options.env.GIT_CONFIG_VALUE_1, `AUTHORIZATION: basic ${credential}`) + }) + + it('sends only its own credential when the checkout persisted another', async (t) => { + // actions/checkout persists its token under the same http./.extraheader key, and git + // sends every value of that key. Two Authorization headers make GitHub answer 400. + const http = require('node:http') + const seen = [] + const server = http.createServer((request, response) => { + const { rawHeaders } = request + seen.push( + rawHeaders.filter((value, index) => index % 2 === 1 && /^authorization$/i.test(rawHeaders[index - 1])) + ) + response.writeHead(404).end() + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + t.after(() => server.close()) + + const serverUrl = `http://127.0.0.1:${server.address().port}` + const { core } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + const { work } = repositoryOn('release/4.17.0') + git(work, 'remote', 'set-url', 'origin', `${serverUrl}/stellarwp/plugin.git`) + git(work, 'config', `http.${serverUrl}/.extraheader`, 'AUTHORIZATION: basic cGVyc2lzdGVk') + fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.17.0\n') + const env = commitEnv({ GITHUB_SERVER_URL: serverUrl, GIT_TERMINAL_PROMPT: '0' }) + + // The server has no repository, so the push fails once git has sent its first request. + await assert.rejects(runCommit({ core, exec, env, cwd: work }), /Pushing to release\/4\.17\.0 failed/) + + assert.ok(seen.length > 0, 'git reached the server') + for (const headers of seen) { + assert.deepEqual(headers, [`basic ${basicCredential(env.INPUT_TOKEN)}`]) + } }) it('neither commits nor pushes when nothing changed, and still succeeds', async (t) => { From 2b6a48cdf8dffd44b2fe6c847f11033ffdc83fb6 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Thu, 8 Oct 2026 16:57:41 -0300 Subject: [PATCH 07/27] Say that GitHub refuses an empty required version before the action runs --- .github/actions/release-prep/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md index b87259c..d912173 100644 --- a/.github/actions/release-prep/README.md +++ b/.github/actions/release-prep/README.md @@ -93,8 +93,10 @@ changelogger's `stellarwp` versioning accepts, so a version the changelog step w before pup has touched anything. - **An empty version is refused.** A tool that dispatches the workflow for a release with no version set - sends an empty one. The job fails with "No version was given", and the release needs a version before - the step is run again. + sends an empty one. When the workflow declares `version` as required, as the template does, GitHub + refuses that dispatch itself with HTTP 422 ("Required input 'version' not provided") and no run starts. + When a workflow declares it optional, the run starts and the action fails it with "No version was + given". Either way the release needs a version before the step is run again. - **A pre-release is refused.** `4.17.0-beta.1` is rejected by the `stellarwp` versioning, so the action cannot prepare one, even though it is a valid tag name. From e835e287cdd1b137a1d364b117662312f342a724 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Fri, 9 Oct 2026 15:51:36 -0300 Subject: [PATCH 08/27] Refuse a version lower than the one the branch already has --- .github/actions/release-prep/README.md | 6 ++ .github/actions/release-prep/action.yml | 3 +- .github/actions/release-prep/release-prep.js | 69 ++++++++++++- .../actions/release-prep/release-prep.test.js | 97 +++++++++++++++++++ 4 files changed, 172 insertions(+), 3 deletions(-) diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md index d912173..8b01e1f 100644 --- a/.github/actions/release-prep/README.md +++ b/.github/actions/release-prep/README.md @@ -100,6 +100,12 @@ before pup has touched anything. - **A pre-release is refused.** `4.17.0-beta.1` is rejected by the `stellarwp` versioning, so the action cannot prepare one, even though it is a valid tag name. +- **A version lower than the branch's is refused.** pup reads the version the branch already has + (`pup get-version`, the first file in `paths.versions`), and preparing `4.17.0` on a branch at + `4.18.0` fails: it would bump the plugin backwards, so either the version or the branch is wrong. The + same version is accepted, since that is what re-running a preparation finds. When pup can't read a + version, the job only warns. + Each refusal happens before anything is changed, as does a `date` that is not `YYYY-MM-DD`, a `ref` that is not a valid branch name, and a checkout that is not on `ref`. diff --git a/.github/actions/release-prep/action.yml b/.github/actions/release-prep/action.yml index 68f4011..965753e 100644 --- a/.github/actions/release-prep/action.yml +++ b/.github/actions/release-prep/action.yml @@ -18,7 +18,8 @@ inputs: version: description: >- The version to prepare: three or four numeric parts, such as `4.17.0` or `4.17.0.1`. An empty - value and a pre-release such as `4.17.0-beta.1` are refused before anything is changed. + value, a pre-release such as `4.17.0-beta.1`, and a version lower than the one the branch + already has are refused before anything is changed. required: true date: description: >- diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index 56516d3..92dccdb 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -296,6 +296,45 @@ function commitMessage(ref, version) { return `Prepare ${ref} (${version})` } +/** + * Orders two versions by their numeric parts, a missing part counting as 0, so 4.18.0.1 comes after + * 4.18.0 and 4.10.0 after 4.9.0. + * + * @param {string} a A version of numeric dot-separated parts. + * @param {string} b Another one. + * + * @returns {number} -1 when `a` comes first, 1 when `b` does, 0 when they are the same version. + */ +function compareVersions(a, b) { + const left = a.split('.').map(Number) + const right = b.split('.').map(Number) + + for (let index = 0; index < Math.max(left.length, right.length); index++) { + const difference = (left[index] ?? 0) - (right[index] ?? 0) + + if (difference !== 0) { + return difference < 0 ? -1 : 1 + } + } + + return 0 +} + +/** + * Reads the version `pup get-version` printed: the numeric part of its last line, so a suffix such as + * -beta.1 is ignored and a PHP notice printed before it is skipped. + * + * @param {string} output What pup printed. + * + * @returns {string|null} The version, or null when pup printed none (it prints `unknown`). + */ +function readCurrentVersion(output) { + const lines = String(output ?? '').split('\n').map((line) => line.trim()).filter(Boolean) + const match = /^\d+(\.\d+)*/.exec(lines.at(-1) ?? '') + + return match ? match[0] : null +} + /** * Reads a file of the checkout. * @@ -347,8 +386,8 @@ async function runOrFail(exec, command, args, options, what) { * Validates the inputs, then bumps the version and replaces the TBDs with pup. * * Every check runs before anything is written, so a wrong version, date or ref, a checkout that is - * not on the release branch, or a repository without a usable .puprc, fails with the branch - * untouched. + * not on the release branch, a repository without a usable .puprc, or a version lower than the one + * the branch already has, fails with the branch untouched. * * @param {object} core The @actions/core toolkit, for the log and the outputs. * @param {object} exec The @actions/exec toolkit. @@ -417,6 +456,30 @@ async function runPrepare({ core, exec, env, cwd = process.cwd() }) { `Downloading pup ${pupVersion}` ) + // A version lower than the one the branch already has would bump it backwards: a wrong version, or + // the wrong branch. The same version is fine, it is what re-running a preparation finds. + const current = readCurrentVersion( + await runOrFail( + exec, + 'php', + ['-d', 'display_errors=stderr', phar, 'get-version'], + { cwd, silent: true }, + 'pup get-version' + ) + ) + + if (current === null) { + core.warning( + `pup could not read the current version from the version files, so ${version} was not ` + + 'checked against it.' + ) + } else if (compareVersions(version, current) < 0) { + throw new Error( + `Refusing to prepare ${version}: ${ref} already has ${current}, a later version. Check the ` + + 'version and the release branch. Nothing was changed.' + ) + } + for (const { command, args } of pupCommands(phar, version)) { await runOrFail(exec, command, args, { cwd }, `pup ${args[1]}`) } @@ -527,5 +590,7 @@ module.exports = { basicCredential, pushEnv, commitMessage, + compareVersions, + readCurrentVersion, VERSION_PATTERN, } diff --git a/tests/actions/release-prep/release-prep.test.js b/tests/actions/release-prep/release-prep.test.js index 4845d10..078078c 100644 --- a/tests/actions/release-prep/release-prep.test.js +++ b/tests/actions/release-prep/release-prep.test.js @@ -20,6 +20,8 @@ const { basicCredential, pushEnv, commitMessage, + compareVersions, + readCurrentVersion, } = require('../../../.github/actions/release-prep/release-prep.js') const { coreWithOutputs, recordingExec } = require('../../support/actions.js') @@ -84,6 +86,7 @@ function allSucceed(before = []) { { command: 'git', args: /^check-ref-format / }, { command: 'git', args: /^symbolic-ref /, stdout: 'release/4.17.0\n' }, { command: 'curl' }, + { command: 'php', args: /get-version$/, stdout: '4.16.0\n' }, { command: 'php', stdout: 'ok' }, ] } @@ -347,6 +350,33 @@ describe('release-prep', () => { }) }) + describe('compareVersions', () => { + it('orders versions by their numeric parts, a missing part counting as 0', () => { + assert.equal(compareVersions('4.17.0', '4.18.0'), -1) + assert.equal(compareVersions('4.18.0', '4.17.9'), 1) + assert.equal(compareVersions('4.18.0', '4.18.0'), 0) + assert.equal(compareVersions('4.18.0', '4.18.0.0'), 0) + assert.equal(compareVersions('4.18.0.1', '4.18.0'), 1) + assert.equal(compareVersions('4.10.0', '4.9.0'), 1) + }) + }) + + describe('readCurrentVersion', () => { + it('reads the version pup prints, ignoring anything before it', () => { + assert.equal(readCurrentVersion('4.18.0\n'), '4.18.0') + assert.equal(readCurrentVersion('PHP Deprecated: something\n4.18.0.1\n'), '4.18.0.1') + }) + + it('reads the numeric part of a version with a suffix', () => { + assert.equal(readCurrentVersion('4.19.0-beta.1\n'), '4.19.0') + }) + + it('returns null when pup could not read a version', () => { + assert.equal(readCurrentVersion('unknown\n'), null) + assert.equal(readCurrentVersion(''), null) + }) + }) + describe('commitMessage', () => { it('names the branch and the version', () => { assert.equal(commitMessage('release/4.17.0', '4.17.0'), 'Prepare release/4.17.0 (4.17.0)') @@ -366,6 +396,7 @@ describe('release-prep', () => { 'git check-ref-format refs/heads/release/4.17.0', 'git symbolic-ref --quiet --short HEAD', `curl -fsSL --retry 3 -o ${phar} https://github.com/stellarwp/pup/releases/download/2.0.0/pup.phar`, + `php -d display_errors=stderr ${phar} get-version`, `php ${phar} replace-version 4.17.0`, `php ${phar} replace-tbd 4.17.0`, ]) @@ -459,6 +490,72 @@ describe('release-prep', () => { assert.ok(!calls.some((call) => call.command === 'curl' || call.command === 'php')) }) + it('refuses a version lower than the one the branch already has, before changing anything', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([{ command: 'php', args: /get-version$/, stdout: '4.18.0\n' }]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv(), cwd }), + /Refusing to prepare 4\.17\.0: release\/4\.17\.0 already has 4\.18\.0.*Nothing was changed/s + ) + assert.ok(!sequence(calls).some((call) => / replace-(version|tbd) /.test(call)), 'pup changed nothing') + assert.deepEqual(outputs(), {}) + }) + + it('compares a four-part version by all four parts', async (t) => { + for (const [current, version, accepted] of [ + ['4.18.0.1', '4.18.0', false], + ['4.18.0.1', '4.18.0.2', true], + ['4.18.0.1', '4.18.1', true], + ]) { + const { core } = await coreWithOutputs(t) + const { exec } = recordingExec( + allSucceed([ + { command: 'git', args: /^symbolic-ref /, stdout: `release/${version}\n` }, + { command: 'php', args: /get-version$/, stdout: `${current}\n` }, + ]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + const env = prepareEnv({ INPUT_VERSION: version, INPUT_REF: `release/${version}` }) + const prepare = runPrepare({ core, exec, env, cwd }) + + if (accepted) { + await prepare + } else { + await assert.rejects(prepare, /already has 4\.18\.0\.1/) + } + } + }) + + it('prepares the version the branch already has, as a re-run of the same release does', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([{ command: 'php', args: /get-version$/, stdout: '4.17.0\n' }]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await runPrepare({ core, exec, env: prepareEnv(), cwd }) + + assert.ok(sequence(calls).some((call) => call.endsWith('replace-version 4.17.0'))) + assert.equal(outputs().version, '4.17.0') + }) + + it('warns and goes on when pup cannot read the current version', async (t) => { + const { core, logged } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([{ command: 'php', args: /get-version$/, stdout: 'unknown\n' }]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await runPrepare({ core, exec, env: prepareEnv(), cwd }) + + assert.match(logged(), /::warning::.*current version/) + assert.ok(sequence(calls).some((call) => call.endsWith('replace-version 4.17.0'))) + }) + it('stops before running pup when the repository has no usable .puprc', async (t) => { const cases = [ [{}, /No \.puprc/], From ce2ef0ee47a66777d7472eb8fcc10eb0d9490777 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Fri, 9 Oct 2026 16:51:05 -0300 Subject: [PATCH 09/27] Say what happens when the branch's version is missing or not numeric --- .github/actions/release-prep/README.md | 5 ++- .github/actions/release-prep/release-prep.js | 45 +++++++++++++------ .../actions/release-prep/release-prep.test.js | 28 ++++++++++-- 3 files changed, 59 insertions(+), 19 deletions(-) diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md index 8b01e1f..86b6621 100644 --- a/.github/actions/release-prep/README.md +++ b/.github/actions/release-prep/README.md @@ -103,8 +103,9 @@ before pup has touched anything. - **A version lower than the branch's is refused.** pup reads the version the branch already has (`pup get-version`, the first file in `paths.versions`), and preparing `4.17.0` on a branch at `4.18.0` fails: it would bump the plugin backwards, so either the version or the branch is wrong. The - same version is accepted, since that is what re-running a preparation finds. When pup can't read a - version, the job only warns. + same version is accepted, since that is what re-running a preparation finds. When the version files + hold something that isn't a numeric version, such as `dev`, the job only warns; when pup can't find a + version at all, it fails, as `replace-version` would. Each refusal happens before anything is changed, as does a `date` that is not `YYYY-MM-DD`, a `ref` that is not a valid branch name, and a checkout that is not on `ref`. diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index 92dccdb..1aa19d2 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -320,17 +320,34 @@ function compareVersions(a, b) { return 0 } +/** + * The last non-empty line of a command's output, where `pup get-version` prints the version after any + * PHP notice. + * + * @param {string} output What the command printed. + * + * @returns {string} The line, trimmed, or an empty string. + */ +function lastLine(output) { + return ( + String(output ?? '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .at(-1) ?? '' + ) +} + /** * Reads the version `pup get-version` printed: the numeric part of its last line, so a suffix such as * -beta.1 is ignored and a PHP notice printed before it is skipped. * * @param {string} output What pup printed. * - * @returns {string|null} The version, or null when pup printed none (it prints `unknown`). + * @returns {string|null} The version, or null when the line holds no numeric version, such as `dev`. */ function readCurrentVersion(output) { - const lines = String(output ?? '').split('\n').map((line) => line.trim()).filter(Boolean) - const match = /^\d+(\.\d+)*/.exec(lines.at(-1) ?? '') + const match = /^\d+(\.\d+)*/.exec(lastLine(output)) return match ? match[0] : null } @@ -457,21 +474,21 @@ async function runPrepare({ core, exec, env, cwd = process.cwd() }) { ) // A version lower than the one the branch already has would bump it backwards: a wrong version, or - // the wrong branch. The same version is fine, it is what re-running a preparation finds. - const current = readCurrentVersion( - await runOrFail( - exec, - 'php', - ['-d', 'display_errors=stderr', phar, 'get-version'], - { cwd, silent: true }, - 'pup get-version' - ) + // the wrong branch. The same version is fine, it is what re-running a preparation finds. When pup + // finds no version at all it exits non-zero, which fails here as replace-version would. + const printed = await runOrFail( + exec, + 'php', + ['-d', 'display_errors=stderr', phar, 'get-version'], + { cwd, silent: true }, + 'pup get-version' ) + const current = readCurrentVersion(printed) if (current === null) { core.warning( - `pup could not read the current version from the version files, so ${version} was not ` + - 'checked against it.' + `The version files hold \`${lastLine(printed)}\`, which is not a numeric version, so ` + + `${version} was not checked against it.` ) } else if (compareVersions(version, current) < 0) { throw new Error( diff --git a/tests/actions/release-prep/release-prep.test.js b/tests/actions/release-prep/release-prep.test.js index 078078c..2114954 100644 --- a/tests/actions/release-prep/release-prep.test.js +++ b/tests/actions/release-prep/release-prep.test.js @@ -543,19 +543,41 @@ describe('release-prep', () => { assert.equal(outputs().version, '4.17.0') }) - it('warns and goes on when pup cannot read the current version', async (t) => { + it('warns and goes on when the branch holds a version that is not numeric', async (t) => { const { core, logged } = await coreWithOutputs(t) const { exec, calls } = recordingExec( - allSucceed([{ command: 'php', args: /get-version$/, stdout: 'unknown\n' }]) + allSucceed([{ command: 'php', args: /get-version$/, stdout: 'dev\n' }]) ) const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) await runPrepare({ core, exec, env: prepareEnv(), cwd }) - assert.match(logged(), /::warning::.*current version/) + assert.match(logged(), /::warning::The version files hold `dev`, which is not a numeric version/) assert.ok(sequence(calls).some((call) => call.endsWith('replace-version 4.17.0'))) }) + it('fails with pup output when pup cannot find the current version, and changes nothing', async (t) => { + // pup refuses to load a .puprc whose version regex finds nothing, so get-version exits 1. + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([ + { + command: 'php', + args: /get-version$/, + exitCode: 1, + stdout: 'ERROR: Could not find version in file plugin.php using regex "/(Version: )(.+)/"', + }, + ]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv(), cwd }), + /pup get-version failed.*Could not find version in file plugin\.php/s + ) + assert.ok(!sequence(calls).some((call) => / replace-(version|tbd) /.test(call)), 'pup changed nothing') + }) + it('stops before running pup when the repository has no usable .puprc', async (t) => { const cases = [ [{}, /No \.puprc/], From 51224133e326cc13c1b47c210eacc9de60d3fe4a Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Mon, 12 Oct 2026 12:13:23 -0300 Subject: [PATCH 10/27] refactor: replace release name samples --- .github/actions/release-prep/README.md | 2 +- .github/actions/release-prep/action.yml | 2 +- .github/actions/release-prep/release-prep.js | 2 +- templates/workflows/release-prep.yml | 4 +- .../actions/release-prep/release-prep.test.js | 56 +++++++++---------- 5 files changed, 33 insertions(+), 33 deletions(-) diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md index 86b6621..dfde7d6 100644 --- a/.github/actions/release-prep/README.md +++ b/.github/actions/release-prep/README.md @@ -114,7 +114,7 @@ that is not a valid branch name, and a checkout that is not on `ref`. | Input | Required | Default | What it does | |---|---|---|---| -| `ref` | yes | | The release branch to push to, e.g. `release/4.17.0`. A branch name, not `refs/heads/…`. The checkout has to be on it; see [Pushing](#pushing) | +| `ref` | yes | | The release branch to push to, e.g. `release/M26.jigglypuff`. A branch name, not `refs/heads/…`. The checkout has to be on it; see [Pushing](#pushing) | | `version` | yes | | The version to prepare, e.g. `4.17.0`. See [Versions it refuses](#versions-it-refuses) | | `date` | no | today, in UTC | The changelog date as `YYYY-MM-DD`. Empty or `today` writes today | | `token` | no | `github.token` | The token the commit is pushed with. See [Pushing](#pushing) | diff --git a/.github/actions/release-prep/action.yml b/.github/actions/release-prep/action.yml index 965753e..295483f 100644 --- a/.github/actions/release-prep/action.yml +++ b/.github/actions/release-prep/action.yml @@ -11,7 +11,7 @@ description: Bumps the version, replaces TBDs and writes the changelog, then pus inputs: ref: description: >- - The release branch to push the preparation to, e.g. `release/4.17.0`. A branch name, not a + The release branch to push the preparation to, e.g. `release/M26.jigglypuff`. A branch name, not a ref. The action does not check it out: the caller's checkout has to be on it, and the action refuses to run when it is not. required: true diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index 1aa19d2..e3c2342 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -115,7 +115,7 @@ function validateRef(ref) { const trimmed = String(ref ?? '').trim() if (trimmed === '') { - throw new Error('No ref was given: pass the release branch to prepare, e.g. release/4.17.0.') + throw new Error('No ref was given: pass the release branch to prepare, e.g. release/M26.jigglypuff.') } // The push writes refs/heads/, so a full ref would create a branch named refs/heads/…. diff --git a/templates/workflows/release-prep.yml b/templates/workflows/release-prep.yml index e8298b6..cdf5c05 100644 --- a/templates/workflows/release-prep.yml +++ b/templates/workflows/release-prep.yml @@ -12,13 +12,13 @@ name: Release Prep # refuses a workflow that requires more. # # Run it locally: you cannot, it pushes to a branch. Start it by hand with -# `gh workflow run release-prep.yml -f ref=release/4.17.0 -f version=4.17.0`. +# `gh workflow run release-prep.yml -f ref=release/M26.jigglypuff -f version=4.17.0`. on: workflow_dispatch: inputs: ref: - description: 'Release branch to prepare and push to, e.g. release/4.17.0' + description: 'Release branch to prepare and push to, e.g. release/M26.jigglypuff' required: true type: string version: diff --git a/tests/actions/release-prep/release-prep.test.js b/tests/actions/release-prep/release-prep.test.js index 2114954..2e6fc86 100644 --- a/tests/actions/release-prep/release-prep.test.js +++ b/tests/actions/release-prep/release-prep.test.js @@ -64,7 +64,7 @@ function checkoutWith(files = {}) { */ function prepareEnv(overrides = {}) { return { - INPUT_REF: 'release/4.17.0', + INPUT_REF: 'release/M26.jigglypuff', INPUT_VERSION: '4.17.0', INPUT_DATE: '', INPUT_PUP_VERSION: '2.0.0', @@ -84,7 +84,7 @@ function allSucceed(before = []) { return [ ...before, { command: 'git', args: /^check-ref-format / }, - { command: 'git', args: /^symbolic-ref /, stdout: 'release/4.17.0\n' }, + { command: 'git', args: /^symbolic-ref /, stdout: 'release/M26.jigglypuff\n' }, { command: 'curl' }, { command: 'php', args: /get-version$/, stdout: '4.16.0\n' }, { command: 'php', stdout: 'ok' }, @@ -185,7 +185,7 @@ async function recordedRealExec() { function commitEnv(overrides = {}) { return { ...process.env, - INPUT_REF: 'release/4.17.0', + INPUT_REF: 'release/M26.jigglypuff', INPUT_VERSION: '4.17.0', INPUT_TOKEN: 'ghs_notarealtoken123', GITHUB_SERVER_URL: 'https://github.com', @@ -250,7 +250,7 @@ describe('release-prep', () => { describe('validateRef', () => { it('accepts a branch name', () => { - assert.equal(validateRef('release/4.17.0'), 'release/4.17.0') + assert.equal(validateRef('release/M26.jigglypuff'), 'release/M26.jigglypuff') }) it('refuses an empty ref', () => { @@ -259,8 +259,8 @@ describe('release-prep', () => { it('refuses a full ref, naming the branch to pass instead', () => { assert.throws( - () => validateRef('refs/heads/release/4.17.0'), - /pass the branch name, e\.g\. 'release\/4\.17\.0'/ + () => validateRef('refs/heads/release/M26.jigglypuff'), + /pass the branch name, e\.g\. 'release\/M26\.jigglypuff'/ ) }) }) @@ -379,7 +379,7 @@ describe('release-prep', () => { describe('commitMessage', () => { it('names the branch and the version', () => { - assert.equal(commitMessage('release/4.17.0', '4.17.0'), 'Prepare release/4.17.0 (4.17.0)') + assert.equal(commitMessage('release/M26.jigglypuff', '4.17.0'), 'Prepare release/M26.jigglypuff (4.17.0)') }) }) @@ -393,7 +393,7 @@ describe('release-prep', () => { const phar = '/runner/temp/pup-2.0.0.phar' assert.deepEqual(sequence(calls), [ - 'git check-ref-format refs/heads/release/4.17.0', + 'git check-ref-format refs/heads/release/M26.jigglypuff', 'git symbolic-ref --quiet --short HEAD', `curl -fsSL --retry 3 -o ${phar} https://github.com/stellarwp/pup/releases/download/2.0.0/pup.phar`, `php -d display_errors=stderr ${phar} get-version`, @@ -404,7 +404,7 @@ describe('release-prep', () => { calls.filter((call) => call.command === 'php').every((call) => call.options.cwd === cwd), 'pup runs in the checkout' ) - assert.deepEqual(outputs(), { version: '4.17.0', date: TODAY, ref: 'release/4.17.0' }) + assert.deepEqual(outputs(), { version: '4.17.0', date: TODAY, ref: 'release/M26.jigglypuff' }) }) it('hands the changelog step a date it can write, never `today`', async (t) => { @@ -471,7 +471,7 @@ describe('release-prep', () => { await assert.rejects( runPrepare({ core, exec, env: prepareEnv(), cwd }), - /The checkout is on 'main', not on 'release\/4\.17\.0'.*Nothing was changed/s + /The checkout is on 'main', not on 'release\/M26\.jigglypuff'.*Nothing was changed/s ) assert.ok(!calls.some((call) => call.command === 'curl' || call.command === 'php')) }) @@ -485,7 +485,7 @@ describe('release-prep', () => { await assert.rejects( runPrepare({ core, exec, env: prepareEnv(), cwd }), - /The checkout is on a detached commit, not on 'release\/4\.17\.0'/ + /The checkout is on a detached commit, not on 'release\/M26\.jigglypuff'/ ) assert.ok(!calls.some((call) => call.command === 'curl' || call.command === 'php')) }) @@ -499,7 +499,7 @@ describe('release-prep', () => { await assert.rejects( runPrepare({ core, exec, env: prepareEnv(), cwd }), - /Refusing to prepare 4\.17\.0: release\/4\.17\.0 already has 4\.18\.0.*Nothing was changed/s + /Refusing to prepare 4\.17\.0: release\/M26\.jigglypuff already has 4\.18\.0.*Nothing was changed/s ) assert.ok(!sequence(calls).some((call) => / replace-(version|tbd) /.test(call)), 'pup changed nothing') assert.deepEqual(outputs(), {}) @@ -660,35 +660,35 @@ describe('release-prep', () => { it('commits every change and pushes it to the release branch', async (t) => { const { core, outputs, summary } = await coreWithOutputs(t) const { exec } = await recordedRealExec() - const { work, remote } = repositoryOn('release/4.17.0') + const { work, remote } = repositoryOn('release/M26.jigglypuff') fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.17.0\n') fs.writeFileSync(path.join(work, 'changelog.txt'), '= 4.17.0 =\n') await runCommit({ core, exec, env: commitEnv(), cwd: work }) - const pushed = git(remote, 'rev-parse', 'refs/heads/release/4.17.0') + const pushed = git(remote, 'rev-parse', 'refs/heads/release/M26.jigglypuff') assert.equal(pushed, git(work, 'rev-parse', 'HEAD')) - assert.equal(git(remote, 'log', '-1', '--format=%s', pushed), 'Prepare release/4.17.0 (4.17.0)') + assert.equal(git(remote, 'log', '-1', '--format=%s', pushed), 'Prepare release/M26.jigglypuff (4.17.0)') assert.equal( git(remote, 'log', '-1', '--format=%an <%ae>', pushed), 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>' ) assert.deepEqual(outputs(), { commit: pushed, files: 'changelog.txt\nplugin.php' }) - assert.match(summary(), /Prepare release\/4\.17\.0 \(4\.17\.0\)/) + assert.match(summary(), /Prepare release\/M26\.jigglypuff \(4\.17\.0\)/) assert.match(summary(), /^- `changelog\.txt`\n- `plugin\.php`$/m) }) it('never puts the token on a command line, in the log or in the git config', async (t) => { const { core, logged } = await coreWithOutputs(t) const { exec, calls, printed } = await recordedRealExec() - const { work } = repositoryOn('release/4.17.0') + const { work } = repositoryOn('release/M26.jigglypuff') fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.17.0\n') const token = commitEnv().INPUT_TOKEN const credential = basicCredential(token) await runCommit({ core, exec, env: commitEnv(), cwd: work }) - assert.match(printed(), /\[command\].*git push origin HEAD:refs\/heads\/release\/4\.17\.0/) + assert.match(printed(), /\[command\].*git push origin HEAD:refs\/heads\/release\/M26\.jigglypuff/) const log = `${logged()}\n${printed()}` assert.match(log, new RegExp(`::add-mask::${token}`)) assert.match(log, new RegExp(`::add-mask::${credential}`)) @@ -699,7 +699,7 @@ describe('release-prep', () => { assert.ok(!fs.readFileSync(path.join(work, '.git', 'config'), 'utf8').includes(credential)) const push = calls.find((call) => call.args[0] === 'push') - assert.deepEqual(push.args, ['push', 'origin', 'HEAD:refs/heads/release/4.17.0']) + assert.deepEqual(push.args, ['push', 'origin', 'HEAD:refs/heads/release/M26.jigglypuff']) assert.equal(push.options.env.GIT_CONFIG_VALUE_1, `AUTHORIZATION: basic ${credential}`) }) @@ -721,14 +721,14 @@ describe('release-prep', () => { const serverUrl = `http://127.0.0.1:${server.address().port}` const { core } = await coreWithOutputs(t) const { exec } = await recordedRealExec() - const { work } = repositoryOn('release/4.17.0') + const { work } = repositoryOn('release/M26.jigglypuff') git(work, 'remote', 'set-url', 'origin', `${serverUrl}/stellarwp/plugin.git`) git(work, 'config', `http.${serverUrl}/.extraheader`, 'AUTHORIZATION: basic cGVyc2lzdGVk') fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.17.0\n') const env = commitEnv({ GITHUB_SERVER_URL: serverUrl, GIT_TERMINAL_PROMPT: '0' }) // The server has no repository, so the push fails once git has sent its first request. - await assert.rejects(runCommit({ core, exec, env, cwd: work }), /Pushing to release\/4\.17\.0 failed/) + await assert.rejects(runCommit({ core, exec, env, cwd: work }), /Pushing to release\/M26\.jigglypuff failed/) assert.ok(seen.length > 0, 'git reached the server') for (const headers of seen) { @@ -740,12 +740,12 @@ describe('release-prep', () => { // A retry of the same version finds the branch already prepared. const { core, outputs } = await coreWithOutputs(t) const { exec, calls } = await recordedRealExec() - const { work, remote } = repositoryOn('release/4.17.0') - const before = git(remote, 'rev-parse', 'refs/heads/release/4.17.0') + const { work, remote } = repositoryOn('release/M26.jigglypuff') + const before = git(remote, 'rev-parse', 'refs/heads/release/M26.jigglypuff') await runCommit({ core, exec, env: commitEnv(), cwd: work }) - assert.equal(git(remote, 'rev-parse', 'refs/heads/release/4.17.0'), before) + assert.equal(git(remote, 'rev-parse', 'refs/heads/release/M26.jigglypuff'), before) assert.ok(!calls.some((call) => call.args.includes('commit') || call.args.includes('push'))) assert.deepEqual(outputs(), { commit: '', files: '' }) }) @@ -753,21 +753,21 @@ describe('release-prep', () => { it('fails with git output when the push is refused', async (t) => { const { core } = await coreWithOutputs(t) const { exec } = await recordedRealExec() - const { work, remote } = repositoryOn('release/4.17.0') + const { work, remote } = repositoryOn('release/M26.jigglypuff') // Someone else pushed to the branch after the checkout, so the push is not a fast-forward. const other = path.join(path.dirname(remote), 'other') - git(path.dirname(remote), 'clone', '--quiet', '-b', 'release/4.17.0', remote, other) + git(path.dirname(remote), 'clone', '--quiet', '-b', 'release/M26.jigglypuff', remote, other) git(other, 'config', 'user.name', 'Other') git(other, 'config', 'user.email', 'other@example.com') git(other, 'commit', '--quiet', '--allow-empty', '-m', 'Elsewhere') - git(other, 'push', '--quiet', 'origin', 'HEAD:refs/heads/release/4.17.0') + git(other, 'push', '--quiet', 'origin', 'HEAD:refs/heads/release/M26.jigglypuff') fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.17.0\n') await assert.rejects( runCommit({ core, exec, env: commitEnv(), cwd: work }), - /Pushing to release\/4\.17\.0 failed.*rejected/s + /Pushing to release\/M26\.jigglypuff failed.*rejected/s ) }) }) From 9a41799f7286e2ba26550b4be6f3003e41bc382f Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Thu, 15 Oct 2026 18:30:18 -0300 Subject: [PATCH 11/27] docs(release-prep): state the warning behavior without the evaluative framing --- .github/actions/release-prep/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md index dfde7d6..8d3662c 100644 --- a/.github/actions/release-prep/README.md +++ b/.github/actions/release-prep/README.md @@ -77,7 +77,7 @@ The action checks the first before running anything, because pup does not: a mis is not JSON, and one with no `paths.versions` all make pup change nothing and exit 0, and the branch would be pushed with the old version. Each of them fails the job here with the branch untouched. -Two gaps are warnings rather than failures, since the job can still do something useful: +Two gaps only produce a warning; the run continues and does its work: - No `checks.tbd.dirs`: pup falls back to its default and replaces TBDs under `src/` only. - No `changelogger` section: the changelogger falls back to its defaults and writes a keepachangelog From e7c13da7fe7e8e7999ac50206583e7055d753e2f Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Thu, 15 Oct 2026 18:31:35 -0300 Subject: [PATCH 12/27] docs(release-prep): scope the version rule to the default changelogger strategy --- .github/actions/release-prep/README.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md index 8d3662c..9215712 100644 --- a/.github/actions/release-prep/README.md +++ b/.github/actions/release-prep/README.md @@ -88,17 +88,20 @@ action runs. One written in TypeScript has to be built in an earlier step. ## Versions it refuses -The version has to be three or four numeric parts, such as `4.17.0` or `4.17.0.1`. That is what the -changelogger's `stellarwp` versioning accepts, so a version the changelog step would fail on is refused -before pup has touched anything. +The version has to be three or four numeric parts, such as `4.17.0` or `4.17.0.1`. That is the shape +the changelogger's default `stellarwp` versioning accepts. A repository with a custom changelogger +version strategy may accept other shapes, but this action still takes three or four numeric parts: +a version the changelog step would fail on is refused before pup has touched anything, whatever the +strategy. - **An empty version is refused.** A tool that dispatches the workflow for a release with no version set sends an empty one. When the workflow declares `version` as required, as the template does, GitHub refuses that dispatch itself with HTTP 422 ("Required input 'version' not provided") and no run starts. When a workflow declares it optional, the run starts and the action fails it with "No version was given". Either way the release needs a version before the step is run again. -- **A pre-release is refused.** `4.17.0-beta.1` is rejected by the `stellarwp` versioning, so the action - cannot prepare one, even though it is a valid tag name. +- **A pre-release is refused.** `4.17.0-beta.1` is rejected by the `stellarwp` versioning, so the + action cannot prepare one, even though it is a valid tag name. A custom version strategy that + accepts pre-releases does not change this. - **A version lower than the branch's is refused.** pup reads the version the branch already has (`pup get-version`, the first file in `paths.versions`), and preparing `4.17.0` on a branch at From 936eed4eb8b060d704cbf4643edd660b419936a6 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Thu, 15 Oct 2026 18:39:23 -0300 Subject: [PATCH 13/27] refactor(release-prep): rename the branch input from ref to branch The input only ever accepts a branch name, never a full ref, so the name said something the value could not be. The workflow-level input of the template stays `ref`: that is the dispatch interface a release tool sends, unchanged. --- .github/actions/release-prep/README.md | 10 ++-- .github/actions/release-prep/action.yml | 12 ++--- .github/actions/release-prep/release-prep.js | 54 +++++++++---------- templates/workflows/release-prep.yml | 2 +- .../actions/release-prep/release-prep.test.js | 24 ++++----- 5 files changed, 51 insertions(+), 51 deletions(-) diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md index 9215712..74f6ab5 100644 --- a/.github/actions/release-prep/README.md +++ b/.github/actions/release-prep/README.md @@ -11,7 +11,7 @@ changelog, then commits that and pushes it to the branch: - uses: stellarwp/plugin-toolbox/.github/actions/release-prep@v1 with: - ref: ${{ inputs.ref }} + branch: ${{ inputs.ref }} version: ${{ inputs.version }} ``` @@ -110,14 +110,14 @@ strategy. hold something that isn't a numeric version, such as `dev`, the job only warns; when pup can't find a version at all, it fails, as `replace-version` would. -Each refusal happens before anything is changed, as does a `date` that is not `YYYY-MM-DD`, a `ref` -that is not a valid branch name, and a checkout that is not on `ref`. +Each refusal happens before anything is changed, as does a `date` that is not `YYYY-MM-DD`, a `branch` +that is not a valid branch name, and a checkout that is not on `branch`. ## Inputs | Input | Required | Default | What it does | |---|---|---|---| -| `ref` | yes | | The release branch to push to, e.g. `release/M26.jigglypuff`. A branch name, not `refs/heads/…`. The checkout has to be on it; see [Pushing](#pushing) | +| `branch` | yes | | The release branch to push to, e.g. `release/M26.jigglypuff`. A branch name, not `refs/heads/…`. The checkout has to be on it; see [Pushing](#pushing) | | `version` | yes | | The version to prepare, e.g. `4.17.0`. See [Versions it refuses](#versions-it-refuses) | | `date` | no | today, in UTC | The changelog date as `YYYY-MM-DD`. Empty or `today` writes today | | `token` | no | `github.token` | The token the commit is pushed with. See [Pushing](#pushing) | @@ -168,7 +168,7 @@ before adding its own header, so only `token` is sent. The push is a fast-forward. If the branch moved after the checkout, the push is refused and the job fails with git's output, rather than overwriting what landed. -The checkout has to be on `ref` itself, and the action checks that before it changes anything. Pushing +The checkout has to be on `branch` itself, and the action checks that before it changes anything. Pushing from another branch would also be a fast-forward whenever the release branch is an ancestor of it, for instance a run started from `main` whose checkout was given no `ref`, and the release would pick up every commit of that branch. A detached checkout, of a tag or a commit, is refused for the same diff --git a/.github/actions/release-prep/action.yml b/.github/actions/release-prep/action.yml index 295483f..c1323dd 100644 --- a/.github/actions/release-prep/action.yml +++ b/.github/actions/release-prep/action.yml @@ -9,11 +9,11 @@ name: Release prep description: Bumps the version, replaces TBDs and writes the changelog, then pushes it to the release branch inputs: - ref: + branch: description: >- - The release branch to push the preparation to, e.g. `release/M26.jigglypuff`. A branch name, not a - ref. The action does not check it out: the caller's checkout has to be on it, and the action - refuses to run when it is not. + The release branch to push the preparation to, e.g. `release/M26.jigglypuff`. A branch name, not + any git ref. The action does not check it out: the caller's checkout has to be on it, and the + action refuses to run when it is not. required: true version: description: >- @@ -66,7 +66,7 @@ runs: 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_REF: ${{ inputs.ref }} + INPUT_BRANCH: ${{ inputs.branch }} INPUT_VERSION: ${{ inputs.version }} INPUT_DATE: ${{ inputs.date }} INPUT_PUP_VERSION: ${{ inputs.pup-version }} @@ -90,7 +90,7 @@ runs: env: ACTION_PATH: ${{ github.action_path }} # The values the prepare step validated, not the raw inputs. - INPUT_REF: ${{ steps.prepare.outputs.ref }} + INPUT_BRANCH: ${{ steps.prepare.outputs.branch }} INPUT_VERSION: ${{ steps.prepare.outputs.version }} INPUT_TOKEN: ${{ inputs.token }} with: diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index e3c2342..bfad127 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -105,17 +105,17 @@ function resolveDate(date, now = new Date()) { * Checks the shape of the branch to push to. Whether git accepts the name is checked by git, in * runPrepare. * - * @param {string} ref The `ref` input. + * @param {string} branch The `branch` input. * * @throws {Error} When it is empty or a full ref. * * @returns {string} The branch name, without surrounding whitespace. */ -function validateRef(ref) { - const trimmed = String(ref ?? '').trim() +function validateBranch(branch) { + const trimmed = String(branch ?? '').trim() if (trimmed === '') { - throw new Error('No ref was given: pass the release branch to prepare, e.g. release/M26.jigglypuff.') + throw new Error('No branch was given: pass the release branch to prepare, e.g. release/M26.jigglypuff.') } // The push writes refs/heads/, so a full ref would create a branch named refs/heads/…. @@ -287,13 +287,13 @@ function pushEnv(token, serverUrl) { /** * The message of the commit the action pushes. * - * @param {string} ref The release branch. + * @param {string} branch The release branch. * @param {string} version The version prepared. * * @returns {string} The commit message. */ -function commitMessage(ref, version) { - return `Prepare ${ref} (${version})` +function commitMessage(branch, version) { + return `Prepare ${branch} (${version})` } /** @@ -418,19 +418,19 @@ async function runOrFail(exec, command, args, options, what) { async function runPrepare({ core, exec, env, cwd = process.cwd() }) { const version = validateVersion(env.INPUT_VERSION) const date = resolveDate(env.INPUT_DATE) - const ref = validateRef(env.INPUT_REF) + const branch = validateBranch(env.INPUT_BRANCH) const pupVersion = validatePupVersion(env.INPUT_PUP_VERSION) - const refCheck = await exec.getExecOutput('git', ['check-ref-format', `refs/heads/${ref}`], { + const refCheck = await exec.getExecOutput('git', ['check-ref-format', `refs/heads/${branch}`], { cwd, ignoreReturnCode: true, silent: true, }) if (refCheck.exitCode !== 0) { - throw new Error(`'${ref}' is not a valid branch name. Nothing was changed.`) + throw new Error(`'${branch}' is not a valid branch name. Nothing was changed.`) } - // The push writes HEAD to refs/heads/. From any other branch that the release branch is an + // The push writes HEAD to refs/heads/. From any other branch that the release branch is an // ancestor of, git would accept it as a fast-forward and carry that branch's commits into the // release, so the checkout has to be on the release branch itself. const head = await exec.getExecOutput('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], { @@ -438,12 +438,12 @@ async function runPrepare({ core, exec, env, cwd = process.cwd() }) { ignoreReturnCode: true, silent: true, }) - const branch = head.exitCode === 0 ? head.stdout.trim() : '' - if (branch !== ref) { + const headBranch = head.exitCode === 0 ? head.stdout.trim() : '' + if (headBranch !== branch) { throw new Error( - `The checkout is on ${branch ? `'${branch}'` : 'a detached commit'}, not on '${ref}'. Check ` + - 'out the release branch before this action, e.g. actions/checkout with `ref`. Nothing was ' + - 'changed.' + `The checkout is on ${headBranch ? `'${headBranch}'` : 'a detached commit'}, not on '${branch}'. ` + + 'Check out the release branch before this action, e.g. actions/checkout with `ref`. Nothing ' + + 'was changed.' ) } @@ -492,7 +492,7 @@ async function runPrepare({ core, exec, env, cwd = process.cwd() }) { ) } else if (compareVersions(version, current) < 0) { throw new Error( - `Refusing to prepare ${version}: ${ref} already has ${current}, a later version. Check the ` + + `Refusing to prepare ${version}: ${branch} already has ${current}, a later version. Check the ` + 'version and the release branch. Nothing was changed.' ) } @@ -503,7 +503,7 @@ async function runPrepare({ core, exec, env, cwd = process.cwd() }) { core.setOutput('version', version) core.setOutput('date', date) - core.setOutput('ref', ref) + core.setOutput('branch', branch) } /** @@ -522,9 +522,9 @@ async function runPrepare({ core, exec, env, cwd = process.cwd() }) { * @returns {Promise} Resolves once the commit is pushed, or found unnecessary. */ async function runCommit({ core, exec, env, cwd = process.cwd() }) { - const ref = validateRef(env.INPUT_REF) + const branch = validateBranch(env.INPUT_BRANCH) const version = validateVersion(env.INPUT_VERSION) - const message = commitMessage(ref, version) + const message = commitMessage(branch, version) const token = env.INPUT_TOKEN || '' if (token) { @@ -544,11 +544,11 @@ async function runCommit({ core, exec, env, cwd = process.cwd() }) { const files = staged.split('\n').filter(Boolean) if (files.length === 0) { - core.info(`Nothing changed: ${ref} already holds ${version}. Nothing was committed or pushed.`) + core.info(`Nothing changed: ${branch} already holds ${version}. Nothing was committed or pushed.`) core.setOutput('commit', '') core.setOutput('files', '') await core.summary - .addRaw(`Nothing to commit: \`${ref}\` already holds ${version}.`, true) + .addRaw(`Nothing to commit: \`${branch}\` already holds ${version}.`, true) .write() return @@ -578,17 +578,17 @@ async function runCommit({ core, exec, env, cwd = process.cwd() }) { await runOrFail( exec, 'git', - ['push', 'origin', `HEAD:refs/heads/${ref}`], + ['push', 'origin', `HEAD:refs/heads/${branch}`], { cwd, env: { ...env, ...pushEnv(token, env.GITHUB_SERVER_URL || 'https://github.com') } }, - `Pushing to ${ref}` + `Pushing to ${branch}` ) - core.info(`Pushed ${commit} to ${ref}.`) + core.info(`Pushed ${commit} to ${branch}.`) core.setOutput('commit', commit) core.setOutput('files', files.join('\n')) // A Markdown list rather than addList, whose HTML list would show the backticks literally. await core.summary - .addRaw(`Pushed \`${commit.slice(0, 7)}\` "${message}" to \`${ref}\`:\n\n`) + .addRaw(`Pushed \`${commit.slice(0, 7)}\` "${message}" to \`${branch}\`:\n\n`) .addRaw(files.map((file) => `- \`${file}\``).join('\n'), true) .write() } @@ -598,7 +598,7 @@ module.exports = { runCommit, validateVersion, resolveDate, - validateRef, + validateBranch, validatePupVersion, readPuprc, hasChangeloggerConfig, diff --git a/templates/workflows/release-prep.yml b/templates/workflows/release-prep.yml index cdf5c05..ee2809b 100644 --- a/templates/workflows/release-prep.yml +++ b/templates/workflows/release-prep.yml @@ -58,6 +58,6 @@ jobs: - uses: stellarwp/plugin-toolbox/.github/actions/release-prep@v1 with: - ref: ${{ inputs.ref }} + branch: ${{ inputs.ref }} version: ${{ inputs.version }} date: ${{ inputs.date }} diff --git a/tests/actions/release-prep/release-prep.test.js b/tests/actions/release-prep/release-prep.test.js index 2e6fc86..5af1bdf 100644 --- a/tests/actions/release-prep/release-prep.test.js +++ b/tests/actions/release-prep/release-prep.test.js @@ -11,7 +11,7 @@ const { runCommit, validateVersion, resolveDate, - validateRef, + validateBranch, validatePupVersion, readPuprc, hasChangeloggerConfig, @@ -64,7 +64,7 @@ function checkoutWith(files = {}) { */ function prepareEnv(overrides = {}) { return { - INPUT_REF: 'release/M26.jigglypuff', + INPUT_BRANCH: 'release/M26.jigglypuff', INPUT_VERSION: '4.17.0', INPUT_DATE: '', INPUT_PUP_VERSION: '2.0.0', @@ -185,7 +185,7 @@ async function recordedRealExec() { function commitEnv(overrides = {}) { return { ...process.env, - INPUT_REF: 'release/M26.jigglypuff', + INPUT_BRANCH: 'release/M26.jigglypuff', INPUT_VERSION: '4.17.0', INPUT_TOKEN: 'ghs_notarealtoken123', GITHUB_SERVER_URL: 'https://github.com', @@ -248,18 +248,18 @@ describe('release-prep', () => { }) }) - describe('validateRef', () => { + describe('validateBranch', () => { it('accepts a branch name', () => { - assert.equal(validateRef('release/M26.jigglypuff'), 'release/M26.jigglypuff') + assert.equal(validateBranch('release/M26.jigglypuff'), 'release/M26.jigglypuff') }) it('refuses an empty ref', () => { - assert.throws(() => validateRef(''), /No ref was given/) + assert.throws(() => validateBranch(''), /No branch was given/) }) it('refuses a full ref, naming the branch to pass instead', () => { assert.throws( - () => validateRef('refs/heads/release/M26.jigglypuff'), + () => validateBranch('refs/heads/release/M26.jigglypuff'), /pass the branch name, e\.g\. 'release\/M26\.jigglypuff'/ ) }) @@ -404,7 +404,7 @@ describe('release-prep', () => { calls.filter((call) => call.command === 'php').every((call) => call.options.cwd === cwd), 'pup runs in the checkout' ) - assert.deepEqual(outputs(), { version: '4.17.0', date: TODAY, ref: 'release/M26.jigglypuff' }) + assert.deepEqual(outputs(), { version: '4.17.0', date: TODAY, branch: 'release/M26.jigglypuff' }) }) it('hands the changelog step a date it can write, never `today`', async (t) => { @@ -431,7 +431,7 @@ describe('release-prep', () => { [{ INPUT_VERSION: '4.17.0-beta.1' }, /not a version this action can prepare/], [{ INPUT_VERSION: '2.1..21' }, /not a version this action can prepare/], [{ INPUT_DATE: 'ontem' }, /is not a date/], - [{ INPUT_REF: '' }, /No ref was given/], + [{ INPUT_BRANCH: '' }, /No branch was given/], [{ INPUT_PUP_VERSION: 'latest' }, /pup-version/], ] @@ -446,7 +446,7 @@ describe('release-prep', () => { } }) - it('stops before downloading pup when the ref is not a valid branch name', async (t) => { + it('stops before downloading pup when the branch is not a valid name', async (t) => { const { core } = await coreWithOutputs(t) const { exec, calls } = recordingExec( allSucceed([{ command: 'git', args: /^check-ref-format /, exitCode: 1 }]) @@ -454,7 +454,7 @@ describe('release-prep', () => { const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) await assert.rejects( - runPrepare({ core, exec, env: prepareEnv({ INPUT_REF: 'release/..x' }), cwd }), + runPrepare({ core, exec, env: prepareEnv({ INPUT_BRANCH: 'release/..x' }), cwd }), /'release\/\.\.x' is not a valid branch name/ ) assert.deepEqual(sequence(calls), ['git check-ref-format refs/heads/release/..x']) @@ -519,7 +519,7 @@ describe('release-prep', () => { ]) ) const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) - const env = prepareEnv({ INPUT_VERSION: version, INPUT_REF: `release/${version}` }) + const env = prepareEnv({ INPUT_VERSION: version, INPUT_BRANCH: `release/${version}` }) const prepare = runPrepare({ core, exec, env, cwd }) if (accepted) { From 4087f536f5d233f01bd9d74372d4b51d714c9d7b Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Thu, 15 Oct 2026 18:40:19 -0300 Subject: [PATCH 14/27] docs(release-prep): attribute the version shape to the default changelogger strategy --- .github/actions/release-prep/action.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/actions/release-prep/action.yml b/.github/actions/release-prep/action.yml index c1323dd..2a4aaed 100644 --- a/.github/actions/release-prep/action.yml +++ b/.github/actions/release-prep/action.yml @@ -17,9 +17,10 @@ inputs: required: true version: description: >- - The version to prepare: three or four numeric parts, such as `4.17.0` or `4.17.0.1`. An empty - value, a pre-release such as `4.17.0-beta.1`, and a version lower than the one the branch - already has are refused before anything is changed. + The version to prepare: three or four numeric parts, such as `4.17.0` or `4.17.0.1`. That is + the shape the default `stellarwp` changelogger versioning takes; a custom version strategy + does not widen what the action accepts. An empty value and a version lower than the one the + branch already has are refused before anything is changed. required: true date: description: >- From 0214ed1e0b45d08e51dea19059501a1afc78e0fd Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Thu, 15 Oct 2026 18:48:13 -0300 Subject: [PATCH 15/27] feat(release-prep): resolve the latest pup release instead of a hardcoded default pup-version now takes a release number or 'latest' (the default). A pinned release still downloads straight from its release page, with no API call; 'latest' reads the newest release through the GitHub API, authenticated with the action token so the runner's shared unauthenticated quota is not spent. --- .github/actions/release-prep/README.md | 16 ++++- .github/actions/release-prep/action.yml | 10 ++- .github/actions/release-prep/release-prep.js | 67 ++++++++++++++++--- .../actions/release-prep/release-prep.test.js | 49 +++++++++++++- 4 files changed, 125 insertions(+), 17 deletions(-) diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md index 74f6ab5..bd29453 100644 --- a/.github/actions/release-prep/README.md +++ b/.github/actions/release-prep/README.md @@ -37,8 +37,8 @@ Then everything the working tree changed is committed as `Prepare (- + The pup release that bumps the version and replaces the TBDs: a release number, such as + 2.0.0, or 'latest' for the newest release. Versions up to 2.0.0 ship as a .phar; once pup + publishes a release without one, the action runs that release's own GitHub Action instead. required: false - default: '2.0.0' + default: 'latest' outputs: version: @@ -71,6 +74,9 @@ runs: INPUT_VERSION: ${{ inputs.version }} INPUT_DATE: ${{ inputs.date }} INPUT_PUP_VERSION: ${{ inputs.pup-version }} + # Authenticates the 'latest' lookup against the GitHub API; a pinned pup-version + # never calls the API. + INPUT_TOKEN: ${{ inputs.token }} with: script: | const { runPrepare } = require(`${process.env.ACTION_PATH}/release-prep.js`) diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index bfad127..9b238e3 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -141,13 +141,53 @@ function validateBranch(branch) { function validatePupVersion(version) { const trimmed = String(version ?? '').trim() - if (!PUP_VERSION_PATTERN.test(trimmed)) { - throw new Error(`pup-version '${trimmed}' is not a pup release number, such as 2.0.0.`) + if (trimmed.toLowerCase() !== 'latest' && !PUP_VERSION_PATTERN.test(trimmed)) { + throw new Error(`pup-version '${trimmed}' is not a pup release number, such as 2.0.0, or 'latest'.`) } return trimmed } +/** + * Resolves the `pup-version` input to a concrete pup release. + * + * A pinned release number is used as it is: the download from the release page fails on its + * own when the release does not exist, so there is no API call for the common case. `latest` + * asks the GitHub API for the newest release, authenticated with the action token when the + * caller passes one, so the runner's shared unauthenticated quota is not spent. + * + * @param {object} options + * @param {string} options.version The validated `pup-version` input. + * @param {string} options.token The action token, possibly empty. + * @param {Function} options.fetchImpl What `fetch` to use; a test passes a stand-in. + * + * @throws {Error} When the latest release cannot be read. + * + * @returns {Promise} The release tag to download from. + */ +async function resolvePupRelease({ version, token, fetchImpl }) { + if (version.toLowerCase() !== 'latest') { + return version + } + + const headers = {} + if (token) { + headers.authorization = `Bearer ${token}` + } + + const response = await fetchImpl('https://api.github.com/repos/stellarwp/pup/releases/latest', { + headers, + }) + + if (!response.ok) { + throw new Error( + `Reading the latest pup release failed (HTTP ${response.status}). Pass a pinned pup-version and run this step again. Nothing was changed.` + ) + } + + return (await response.json()).tag_name +} + /** * Reads what the action needs from the repository's .puprc. * @@ -406,16 +446,17 @@ async function runOrFail(exec, command, args, options, what) { * not on the release branch, a repository without a usable .puprc, or a version lower than the one * the branch already has, fails with the branch untouched. * - * @param {object} core The @actions/core toolkit, for the log and the outputs. - * @param {object} exec The @actions/exec toolkit. - * @param {object} env The environment the action manifest put its inputs in. - * @param {string} cwd The checkout, the working directory by default. + * @param {object} core The @actions/core toolkit, for the log and the outputs. + * @param {object} exec The @actions/exec toolkit. + * @param {object} env The environment the action manifest put its inputs in. + * @param {string} cwd The checkout, the working directory by default. + * @param {Function} fetchImpl What `fetch` to use; a test passes a stand-in. * * @throws {Error} When an input is wrong, .puprc is unusable, or the download or pup fails. * * @returns {Promise} Resolves once pup has run and the outputs are set. */ -async function runPrepare({ core, exec, env, cwd = process.cwd() }) { +async function runPrepare({ core, exec, env, cwd = process.cwd(), fetchImpl = globalThis.fetch }) { const version = validateVersion(env.INPUT_VERSION) const date = resolveDate(env.INPUT_DATE) const branch = validateBranch(env.INPUT_BRANCH) @@ -463,14 +504,19 @@ async function runPrepare({ core, exec, env, cwd = process.cwd() }) { ) } - const phar = path.join(env.RUNNER_TEMP || os.tmpdir(), `pup-${pupVersion}.phar`) + const pupRelease = await resolvePupRelease({ + version: pupVersion, + token: env.INPUT_TOKEN || '', + fetchImpl, + }) + const phar = path.join(env.RUNNER_TEMP || os.tmpdir(), `pup-${pupRelease}.phar`) await runOrFail( exec, 'curl', - ['-fsSL', '--retry', '3', '-o', phar, pupDownloadUrl(pupVersion)], + ['-fsSL', '--retry', '3', '-o', phar, pupDownloadUrl(pupRelease)], {}, - `Downloading pup ${pupVersion}` + `Downloading pup ${pupRelease}` ) // A version lower than the one the branch already has would bump it backwards: a wrong version, or @@ -600,6 +646,7 @@ module.exports = { resolveDate, validateBranch, validatePupVersion, + resolvePupRelease, readPuprc, hasChangeloggerConfig, pupDownloadUrl, diff --git a/tests/actions/release-prep/release-prep.test.js b/tests/actions/release-prep/release-prep.test.js index 5af1bdf..46e0c28 100644 --- a/tests/actions/release-prep/release-prep.test.js +++ b/tests/actions/release-prep/release-prep.test.js @@ -270,8 +270,12 @@ describe('release-prep', () => { assert.equal(validatePupVersion('2.0.0'), '2.0.0') }) + it('accepts latest', () => { + assert.equal(validatePupVersion('latest'), 'latest') + }) + it('refuses anything else', () => { - for (const version of ['', 'latest', '2.0.0/../../x', '2.0']) { + for (const version of ['', '2.0.0/../../x', '2.0']) { assert.throws(() => validatePupVersion(version), /pup-version/) } }) @@ -432,7 +436,6 @@ describe('release-prep', () => { [{ INPUT_VERSION: '2.1..21' }, /not a version this action can prepare/], [{ INPUT_DATE: 'ontem' }, /is not a date/], [{ INPUT_BRANCH: '' }, /No branch was given/], - [{ INPUT_PUP_VERSION: 'latest' }, /pup-version/], ] for (const [overrides, message] of cases) { @@ -654,6 +657,48 @@ describe('release-prep', () => { /Downloading pup 2\.0\.0 failed.*404/s ) }) + + it('resolves latest to the newest pup release and downloads that phar', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + const fetched = [] + const fetchImpl = async (url, options) => { + fetched.push([url, options]) + return { ok: true, json: async () => ({ tag_name: '2.0.0' }) } + } + + await runPrepare({ + core, + exec, + env: prepareEnv({ INPUT_PUP_VERSION: 'latest', INPUT_TOKEN: 'ghp_example' }), + cwd, + fetchImpl, + }) + + assert.deepEqual(fetched, [ + ['https://api.github.com/repos/stellarwp/pup/releases/latest', { headers: { authorization: 'Bearer ghp_example' } }], + ]) + assert.ok( + sequence(calls).some((call) => call.includes('pup/releases/download/2.0.0/pup.phar')), + 'downloads the phar the resolved release points at' + ) + }) + + it('does not call the GitHub API for a pinned pup version', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + let called = 0 + const fetchImpl = async () => { + called += 1 + return { ok: true, json: async () => ({}) } + } + + await runPrepare({ core, exec, env: prepareEnv(), cwd, fetchImpl }) + + assert.equal(called, 0, 'a pinned version downloads straight from the release URL') + }) }) describe('runCommit', () => { From 5232e2c6b54052afa7c5237a7883946fa6afe544 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Thu, 15 Oct 2026 18:52:32 -0300 Subject: [PATCH 16/27] refactor: share compareVersions between the release-prep and resolve-floating-tags actions Both actions carried the same part-by-part version ordering. It now lives in .github/lib/versions.js, which ships in the same repository checkout the runner fetches for the action: one implementation, no package to publish, nothing for a caller to install. --- .github/actions/release-prep/release-prep.js | 26 +-------- .../actions/resolve-floating-tags/resolve.js | 46 +-------------- .github/lib/versions.js | 57 +++++++++++++++++++ 3 files changed, 60 insertions(+), 69 deletions(-) create mode 100644 .github/lib/versions.js diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index 9b238e3..ad42666 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -14,6 +14,8 @@ const fs = require('node:fs') const os = require('node:os') const path = require('node:path') +const { compareVersions } = require('../../lib/versions.js') + /** * Three or four numeric parts. It is what the stellarwp changelog versioning accepts, minus its * blind spot: it reads an empty part as 0, so it would take 2.1..21. A version of this shape is @@ -336,30 +338,6 @@ function commitMessage(branch, version) { return `Prepare ${branch} (${version})` } -/** - * Orders two versions by their numeric parts, a missing part counting as 0, so 4.18.0.1 comes after - * 4.18.0 and 4.10.0 after 4.9.0. - * - * @param {string} a A version of numeric dot-separated parts. - * @param {string} b Another one. - * - * @returns {number} -1 when `a` comes first, 1 when `b` does, 0 when they are the same version. - */ -function compareVersions(a, b) { - const left = a.split('.').map(Number) - const right = b.split('.').map(Number) - - for (let index = 0; index < Math.max(left.length, right.length); index++) { - const difference = (left[index] ?? 0) - (right[index] ?? 0) - - if (difference !== 0) { - return difference < 0 ? -1 : 1 - } - } - - return 0 -} - /** * The last non-empty line of a command's output, where `pup get-version` prints the version after any * PHP notice. diff --git a/.github/actions/resolve-floating-tags/resolve.js b/.github/actions/resolve-floating-tags/resolve.js index 018140e..02f98c3 100644 --- a/.github/actions/resolve-floating-tags/resolve.js +++ b/.github/actions/resolve-floating-tags/resolve.js @@ -10,6 +10,7 @@ /** A version is numbers separated by dots, at least two of them. */ const NUMERIC_VERSION = /^\d+(\.\d+)+$/ +const { toParts, compareVersions } = require('../../lib/versions.js') /** How many leading parts of the version each level's tag name carries. */ const LEVEL_DEPTHS = { major: 1, minor: 2 } @@ -27,51 +28,6 @@ function toVersion(tagName) { return tagName.replace(/^v/, '').replace(/\+.*$/, '') } -/** - * Splits a version into its numbers, for comparing one part against another. - * - * @param {string} version A numeric version, e.g. `1.2.3`. - * - * @returns {number[]} Its parts, e.g. `[1, 2, 3]`. - */ -function toParts(version) { - return version.split('.').map(Number) -} - -/** - * Orders two versions by their numbers. 1.10.0 is above 1.9.0, and 1.2.3.1 above 1.2.3, neither of - * which a string comparison gets right. Missing trailing parts count as zero, so 1.2.3 and 1.2.3.0 - * are the same version. - * - * The semver package cannot express a version with four parts: 1.2.3.4 is invalid with or without - * its `loose` option, and comparing one throws. `coerce` drops the fourth part, so a 1.2.3.1 hotfix - * would compare equal to the 1.2.3 it patches and v1.2 would stop moving to a hotfix. A dependency - * would also have to be installed by every repository calling this action, since github-script - * resolves one from the calling repository's directory. - * - * semver does have prerelease precedence, ordering 1.4.0-rc.1 below 1.4.0, and this does not. No - * suffixed version reaches this function: releasedVersions keeps only numeric versions, and `core` - * has any suffix stripped. See Prereleases in the action's README. - * - * @param {string} a A numeric version. - * @param {string} b The version to compare it against. - * - * @returns {number} -1 when `a` is lower, 1 when it is higher, 0 when they are the same version. - */ -function compareVersions(a, b) { - const left = toParts(a) - const right = toParts(b) - - for (let i = 0; i < Math.max(left.length, right.length); i++) { - const difference = (left[i] ?? 0) - (right[i] ?? 0) - if (difference !== 0) { - return difference < 0 ? -1 : 1 - } - } - - return 0 -} - /** * Whether a version belongs to the line a floating tag covers. The line is named by its leading * parts, and a member has to carry at least one part beyond them: 1.2.3 and 1.2.3.1 are both in the diff --git a/.github/lib/versions.js b/.github/lib/versions.js new file mode 100644 index 0000000..d9f840e --- /dev/null +++ b/.github/lib/versions.js @@ -0,0 +1,57 @@ +'use strict' + +/** + * Numeric version parts, shared by the toolbox actions. + * + * 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 + * and nothing for a caller to install. + */ + +/** + * Splits a version into its numbers, for comparing one part against another. + * + * @param {string} version A numeric version, e.g. `1.2.3`. + * + * @returns {number[]} Its parts, e.g. `[1, 2, 3]`. + */ +function toParts(version) { + return version.split('.').map(Number) +} + +/** + * Orders two versions by their numbers. 1.10.0 is above 1.9.0, and 1.2.3.1 above 1.2.3, neither of + * which a string comparison gets right. Missing trailing parts count as zero, so 1.2.3 and 1.2.3.0 + * are the same version. + * + * The semver package cannot express a version with four parts: 1.2.3.4 is invalid with or without + * its `loose` option, and comparing one throws. `coerce` drops the fourth part, so a 1.2.3.1 hotfix + * would compare equal to the 1.2.3 it patches and v1.2 would stop moving to a hotfix. A dependency + * would also have to be installed by every repository calling an action that uses this, since + * github-script resolves one from the calling repository's directory. + * + * semver does have prerelease precedence, ordering 1.4.0-rc.1 below 1.4.0, and this does not. No + * suffixed version reaches this function: the actions keep only numeric versions by the time they + * compare. + * + * @param {string} a A numeric version. + * @param {string} b The version to compare it against. + * + * @returns {number} -1 when `a` is lower, 1 when it is higher, 0 when they are the same version. + */ +function compareVersions(a, b) { + const left = toParts(a) + const right = toParts(b) + + for (let i = 0; i < Math.max(left.length, right.length); i++) { + const difference = (left[i] ?? 0) - (right[i] ?? 0) + if (difference !== 0) { + return difference < 0 ? -1 : 1 + } + } + + return 0 +} + +module.exports = { toParts, compareVersions } From 4aae955f947a2e03860ee3887d123c8b671d33d2 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Thu, 15 Oct 2026 19:05:16 -0300 Subject: [PATCH 17/27] feat(release-prep): run whichever distribution a pup release publishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A release's pup.phar asset decides how the preparation runs, not a version comparison: the phar is there and the action runs it as it always has; it answers 404 and the release is the TypeScript line, which runs through its own GitHub Action. The manifest carries those calls already — conditioned on the distribution the prepare step reports, so they stay inert until a phar-less release exists — spelling out the interface the pup action is expected to ship, the changelogger pattern this action already uses. uses: only takes a static ref, so the floating major tag @v3 is the moving pin. The branch-version check stays on the phar path for now; the TS contract does not define it. The asset check is a HEAD request, not the releases API, so a pinned pup-version stays free of API calls. --- .github/actions/release-prep/README.md | 22 ++++- .github/actions/release-prep/action.yml | 17 ++++ .github/actions/release-prep/release-prep.js | 85 +++++++++++++++++-- .../actions/release-prep/release-prep.test.js | 52 +++++++++++- 4 files changed, 163 insertions(+), 13 deletions(-) diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md index bd29453..df16b58 100644 --- a/.github/actions/release-prep/README.md +++ b/.github/actions/release-prep/README.md @@ -139,8 +139,26 @@ A pinned release downloads straight from its release page. `latest` reads the ne through the GitHub API, authenticated with `token` so the runner's shared unauthenticated quota is not spent. -The release's `pup.phar` asset is downloaded into the runner's temp directory and runs on the -runner's PHP, so it never ends up in the commit. +Which kind of pup the release carries is decided from the release itself, not from a version +comparison: the action sends a HEAD request to the release's `pup.phar` asset, and a release +that answers 404 is the TypeScript line, which runs through its own GitHub Action. Releases up +to and including 2.0.0 publish a `pup.phar` and run exactly as they always have: the phar +downloads into the runner's temp directory and runs on the runner's PHP, so it never ends up +in the commit. While every release carries a phar, the other path never runs; the day one does +not, the action calls `stellarwp/pup@v3` — the same way the changelog step calls the +changelogger action — and callers change nothing. + +The manifest spells that call out now, as the contract the pup action is expected to ship: + +```yaml +- uses: stellarwp/pup@v3 + with: + command: replace-version --version=4.17.0 +``` + +`uses:` takes a static ref, so the floating major tag is the moving pin. Until a phar-less +release exists the branch-version check — the one that refuses to prepare a version lower than +the branch already has — runs on the phar path only; the TS contract does not define it yet. ## Running it again diff --git a/.github/actions/release-prep/action.yml b/.github/actions/release-prep/action.yml index 1f5653f..42249d6 100644 --- a/.github/actions/release-prep/action.yml +++ b/.github/actions/release-prep/action.yml @@ -82,6 +82,23 @@ runs: const { runPrepare } = require(`${process.env.ACTION_PATH}/release-prep.js`) await runPrepare({ core, exec, env: process.env }) + # The TypeScript line of pup publishes no .phar; its release runs through its own GitHub + # Action, called here the way the changelog step calls the changelogger. These steps run + # only when the prepare step resolved a release without a phar asset, so they stay inert + # until such a release exists — and spell out the interface the pup action is expected to + # ship. uses: takes a static ref, so the floating major tag is the moving pin. + - name: Bump the version (pup) + if: ${{ steps.prepare.outputs.distribution == 'ts' }} + uses: stellarwp/pup@v3 + with: + command: replace-version --version=${{ steps.prepare.outputs.version }} + + - name: Replace the TBDs (pup) + if: ${{ steps.prepare.outputs.distribution == 'ts' }} + uses: stellarwp/pup@v3 + with: + command: replace-tbd --version=${{ steps.prepare.outputs.version }} + # The resolved date, never inputs.date: this action writes the date it is given as it is, so # `today` would land in the changelog as the word and an empty value as an empty date. - name: Write the changelog diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index ad42666..94b413c 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -266,6 +266,43 @@ function pupDownloadUrl(version) { return `https://github.com/stellarwp/pup/releases/download/${version}/pup.phar` } +/** + * Which distribution a pup release carries, decided by looking at the release itself: a `pup.phar` + * asset means the PHP build the action has always run, and its absence means the release expects + * to run through its own GitHub Action (the TypeScript line, once it publishes one). + * + * The check is a HEAD request against the asset URL, not the releases API, so a pinned + * `pup-version` stays free of API calls and rate limits. + * + * @param {object} exec The @actions/exec toolkit. + * @param {string} release The resolved pup release tag. + * + * @throws {Error} When the check itself fails; the run cannot tell which path to take. + * + * @returns {Promise<'phar'|'ts'>} The distribution the release carries. + */ +async function resolvePupDistribution({ exec, release }) { + const head = await exec.getExecOutput('curl', ['-fsSI', pupDownloadUrl(release)], { + ignoreReturnCode: true, + silent: true, + }) + + if (head.exitCode === 0) { + return 'phar' + } + + // curl -f answers a 404 with exit code 22 and the status in stderr. + if (head.exitCode === 22 && /404/.test(head.stderr)) { + return 'ts' + } + + const output = [head.stdout, head.stderr].map((text) => text.trim()).filter(Boolean) + throw new Error( + `Checking the pup ${release} release failed (exit code ${head.exitCode}):\n` + + output.join('\n') + ) +} + /** * The pup commands that prepare a version, in the order they run. * @@ -487,14 +524,51 @@ async function runPrepare({ core, exec, env, cwd = process.cwd(), fetchImpl = gl token: env.INPUT_TOKEN || '', fetchImpl, }) - const phar = path.join(env.RUNNER_TEMP || os.tmpdir(), `pup-${pupRelease}.phar`) + const distribution = await resolvePupDistribution({ exec, release: pupRelease }) + core.setOutput('distribution', distribution) + + if (distribution === 'phar') { + await runPrepareWithPhar({ core, exec, cwd, env, pharRelease: pupRelease, version, branch }) + } else { + // The TypeScript line: the release publishes no phar and runs through its own GitHub + // Action instead. The action manifest carries the calls, conditioned on the distribution + // output this step sets; here only the inputs are validated. The branch-version check + // below is part of the phar path — the TS contract does not define it yet. + core.info( + `pup ${pupRelease} publishes no .phar asset; the preparation continues with the release's ` + + 'own GitHub Action (stellarwp/pup@v3). The branch-version check only runs on the phar path.' + ) + } + + core.setOutput('version', version) + core.setOutput('date', date) + core.setOutput('branch', branch) +} + +/** + * Runs the preparation with the phar a pup release publishes: downloads it, checks the version + * the branch already has, then bumps the version and replaces the TBDs. + * + * @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.env The step environment, for RUNNER_TEMP. + * @param {string} options.pharRelease The resolved pup release tag. + * @param {string} options.version The version to prepare, validated. + * @param {string} options.branch The release branch, validated. + * + * @returns {Promise} Resolves once pup has run. + */ +async function runPrepareWithPhar({ core, exec, cwd, env, pharRelease, version, branch }) { + const phar = path.join(env.RUNNER_TEMP || os.tmpdir(), `pup-${pharRelease}.phar`) await runOrFail( exec, 'curl', - ['-fsSL', '--retry', '3', '-o', phar, pupDownloadUrl(pupRelease)], + ['-fsSL', '--retry', '3', '-o', phar, pupDownloadUrl(pharRelease)], {}, - `Downloading pup ${pupRelease}` + `Downloading pup ${pharRelease}` ) // A version lower than the one the branch already has would bump it backwards: a wrong version, or @@ -524,10 +598,6 @@ async function runPrepare({ core, exec, env, cwd = process.cwd(), fetchImpl = gl for (const { command, args } of pupCommands(phar, version)) { await runOrFail(exec, command, args, { cwd }, `pup ${args[1]}`) } - - core.setOutput('version', version) - core.setOutput('date', date) - core.setOutput('branch', branch) } /** @@ -625,6 +695,7 @@ module.exports = { validateBranch, validatePupVersion, resolvePupRelease, + resolvePupDistribution, readPuprc, hasChangeloggerConfig, pupDownloadUrl, diff --git a/tests/actions/release-prep/release-prep.test.js b/tests/actions/release-prep/release-prep.test.js index 46e0c28..6593eef 100644 --- a/tests/actions/release-prep/release-prep.test.js +++ b/tests/actions/release-prep/release-prep.test.js @@ -399,6 +399,7 @@ describe('release-prep', () => { assert.deepEqual(sequence(calls), [ 'git check-ref-format refs/heads/release/M26.jigglypuff', 'git symbolic-ref --quiet --short HEAD', + 'curl -fsSI https://github.com/stellarwp/pup/releases/download/2.0.0/pup.phar', `curl -fsSL --retry 3 -o ${phar} https://github.com/stellarwp/pup/releases/download/2.0.0/pup.phar`, `php -d display_errors=stderr ${phar} get-version`, `php ${phar} replace-version 4.17.0`, @@ -408,7 +409,12 @@ describe('release-prep', () => { calls.filter((call) => call.command === 'php').every((call) => call.options.cwd === cwd), 'pup runs in the checkout' ) - assert.deepEqual(outputs(), { version: '4.17.0', date: TODAY, branch: 'release/M26.jigglypuff' }) + assert.deepEqual(outputs(), { + version: '4.17.0', + date: TODAY, + branch: 'release/M26.jigglypuff', + distribution: 'phar', + }) }) it('hands the changelog step a date it can write, never `today`', async (t) => { @@ -505,7 +511,7 @@ describe('release-prep', () => { /Refusing to prepare 4\.17\.0: release\/M26\.jigglypuff already has 4\.18\.0.*Nothing was changed/s ) assert.ok(!sequence(calls).some((call) => / replace-(version|tbd) /.test(call)), 'pup changed nothing') - assert.deepEqual(outputs(), {}) + assert.deepEqual(outputs(), { distribution: 'phar' }) }) it('compares a four-part version by all four parts', async (t) => { @@ -642,13 +648,15 @@ describe('release-prep', () => { /pup replace-version failed.*No version files found/s ) assert.ok(!sequence(calls).some((call) => call.includes('replace-tbd')), 'replace-tbd never ran') - assert.deepEqual(outputs(), {}) + assert.deepEqual(outputs(), { distribution: 'phar' }) }) it('fails when the pup download fails', async (t) => { const { core } = await coreWithOutputs(t) const { exec } = recordingExec( - allSucceed([{ command: 'curl', exitCode: 22, stderr: 'The requested URL returned error: 404' }]) + allSucceed([ + { command: 'curl', args: /fsSL/, exitCode: 22, stderr: 'The requested URL returned error: 404' }, + ]) ) const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) @@ -699,6 +707,42 @@ describe('release-prep', () => { assert.equal(called, 0, 'a pinned version downloads straight from the release URL') }) + + it('hands the preparation to the release action when the release has no phar', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([ + { command: 'curl', args: /fsSI/, exitCode: 22, stderr: 'The requested URL returned error: 404' }, + ]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await runPrepare({ core, exec, env: prepareEnv(), cwd }) + + assert.deepEqual(outputs(), { + version: '4.17.0', + date: TODAY, + branch: 'release/M26.jigglypuff', + distribution: 'ts', + }) + assert.ok( + !sequence(calls).some((call) => /fsSL|php /.test(call)), + 'no phar is downloaded and no PHP runs without a phar asset' + ) + }) + + it('fails loudly when the phar check itself errors', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec } = recordingExec( + allSucceed([{ command: 'curl', args: /fsSI/, exitCode: 28, stderr: 'Connection timed out' }]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv(), cwd }), + /Checking the pup 2\.0\.0 release failed.*Connection timed out/s + ) + }) }) describe('runCommit', () => { From 4f31db89e9fc07fdaf51a61c760363e360d0c5d6 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Thu, 15 Oct 2026 19:21:31 -0300 Subject: [PATCH 18/27] docs(release-prep): rewrite the README without the generative filler The review called out the docs reading like model output: evaluative framing, asides that explain how to regard a fact, phrases that cushion what is plain. This rewrites the file for a human reader: each sentence states one fact with a literal verb, the refused-version list carries only the rule and its cause, and the operational sections (pushing, dates, reruns) drop the commentary. --- .github/actions/release-prep/README.md | 165 ++++++++++++------------- 1 file changed, 79 insertions(+), 86 deletions(-) diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md index df16b58..c463146 100644 --- a/.github/actions/release-prep/README.md +++ b/.github/actions/release-prep/README.md @@ -1,7 +1,7 @@ # release-prep -Prepares a release branch. It bumps the version, replaces the `@since TBD` placeholders and writes the -changelog, then commits that and pushes it to the branch: +Prepares a release branch: bumps the version, replaces the `@since TBD` placeholders, writes the +changelog, then commits and pushes the result to the branch: ```yaml - uses: actions/checkout@v6 @@ -15,34 +15,32 @@ changelog, then commits that and pushes it to the branch: version: ${{ inputs.version }} ``` -It opens no pull request. The commit lands on the release branch itself, and is reviewed wherever the -release is reviewed, typically the pull request from the release branch to the main branch. - +The action opens no pull request; the commit is reviewed on the release branch's own pull request. [`templates/workflows/release-prep.yml`](../../../templates/workflows/release-prep.yml) is a complete workflow around it, ready to copy into a plugin repository. ## What it changes -Three tools do the work, in this order, from the root of the checkout: +Three tools run, in this order, from the root of the checkout: -1. `pup replace-version ` rewrites the version in every file the `.puprc` lists under +1. `pup replace-version ` rewrites the version in every file `.puprc` lists under `paths.versions`. 2. `pup replace-tbd ` replaces the TBD placeholders (`@since TBD`, `@deprecated TBD`, - `_deprecated_function( __METHOD__, 'TBD' )` and the like) in the directories the `.puprc` lists under + `_deprecated_function( __METHOD__, 'TBD' )` and the like) in the directories `.puprc` lists under `checks.tbd.dirs`. 3. The [changelogger action](https://github.com/stellarwp/changelogger) writes the pending entries in `changelog/` into the changelog files and deletes them, with the version and the date. -Then everything the working tree changed is committed as `Prepare ()` by -`github-actions[bot]` and pushed to `ref`. Everything means everything, so run the action on a clean -checkout, and put any step that writes tracked files after it. +Everything the working tree changed is then committed as `Prepare ()` by +`github-actions[bot]` and pushed to `branch`. Run the action on a clean checkout, and put any step +that writes tracked files after it: the commit includes every change in the working tree. pup is downloaded from its GitHub release into the runner's temp directory, so it never ends up in -the commit. It runs on the runner's PHP. See [Which pup runs](#which-pup-runs). +the commit. See [Which pup runs](#which-pup-runs). ## What the repository needs -A `.puprc` that lists the version files, and the directories that hold the TBD placeholders: +A `.puprc` listing the version files and the directories that hold the TBD placeholders: ```json { @@ -73,44 +71,43 @@ A `changelogger` section in `package.json`, and the pending entries in its `chan } ``` -The action checks the first before running anything, because pup does not: a missing `.puprc`, one that -is not JSON, and one with no `paths.versions` all make pup change nothing and exit 0, and the branch -would be pushed with the old version. Each of them fails the job here with the branch untouched. +The action checks the `.puprc` before running anything, because pup does not: a missing file, one +that is not JSON, or one with no `paths.versions` makes pup change nothing and exit 0. The job +fails on any of the three, with the branch untouched. -Two gaps only produce a warning; the run continues and does its work: +Two gaps only produce a warning; the run continues: - No `checks.tbd.dirs`: pup falls back to its default and replaces TBDs under `src/` only. -- No `changelogger` section: the changelogger falls back to its defaults and writes a keepachangelog - `changelog.md` with semver versioning. +- No `changelogger` section: the changelogger uses its defaults — a keepachangelog `changelog.md` + with semver versioning. -A writing strategy given as a `.js` file is loaded from the checkout, so it has to be there before the +A writing strategy given as a `.js` file is loaded from the checkout; it has to be there before the action runs. One written in TypeScript has to be built in an earlier step. ## Versions it refuses The version has to be three or four numeric parts, such as `4.17.0` or `4.17.0.1`. That is the shape the changelogger's default `stellarwp` versioning accepts. A repository with a custom changelogger -version strategy may accept other shapes, but this action still takes three or four numeric parts: -a version the changelog step would fail on is refused before pup has touched anything, whatever the -strategy. - -- **An empty version is refused.** A tool that dispatches the workflow for a release with no version set - sends an empty one. When the workflow declares `version` as required, as the template does, GitHub - refuses that dispatch itself with HTTP 422 ("Required input 'version' not provided") and no run starts. - When a workflow declares it optional, the run starts and the action fails it with "No version was - given". Either way the release needs a version before the step is run again. +version strategy may accept other shapes, but this action still takes three or four numeric parts. +The changelog step would fail on anything else, so the action refuses it before pup has touched +anything. + +- **An empty version is refused.** A tool that dispatches the workflow for a release with no version + set sends an empty one. When the workflow declares `version` as required, as the template does, + GitHub refuses the dispatch itself with HTTP 422 and no run starts. When the workflow declares it + optional, the run starts and the action fails it with "No version was given". Either way the + release needs a version before the step runs again. - **A pre-release is refused.** `4.17.0-beta.1` is rejected by the `stellarwp` versioning, so the action cannot prepare one, even though it is a valid tag name. A custom version strategy that accepts pre-releases does not change this. - - **A version lower than the branch's is refused.** pup reads the version the branch already has (`pup get-version`, the first file in `paths.versions`), and preparing `4.17.0` on a branch at - `4.18.0` fails: it would bump the plugin backwards, so either the version or the branch is wrong. The - same version is accepted, since that is what re-running a preparation finds. When the version files - hold something that isn't a numeric version, such as `dev`, the job only warns; when pup can't find a + `4.18.0` would bump the plugin backwards: either the version or the branch is wrong. The same + version is accepted; that is what re-running a preparation finds. When the version files hold + something that is not a numeric version, such as `dev`, the job only warns. When pup cannot find a version at all, it fails, as `replace-version` would. -Each refusal happens before anything is changed, as does a `date` that is not `YYYY-MM-DD`, a `branch` +Each refusal happens before anything is changed. So do a `date` that is not `YYYY-MM-DD`, a `branch` that is not a valid branch name, and a checkout that is not on `branch`. ## Inputs @@ -134,21 +131,19 @@ that is not a valid branch name, and a checkout that is not on `branch`. ## Which pup runs -`pup-version` pins a pup release (`2.0.0`) or tracks the newest one (`latest`, the default). -A pinned release downloads straight from its release page. `latest` reads the newest release -through the GitHub API, authenticated with `token` so the runner's shared unauthenticated -quota is not spent. +`pup-version` pins a pup release (`2.0.0`) or tracks the newest one (`latest`, the default). A +pinned release downloads straight from its release page. `latest` reads the newest release through +the GitHub API, authenticated with `token` so the runner's shared unauthenticated quota is not +spent. -Which kind of pup the release carries is decided from the release itself, not from a version -comparison: the action sends a HEAD request to the release's `pup.phar` asset, and a release -that answers 404 is the TypeScript line, which runs through its own GitHub Action. Releases up -to and including 2.0.0 publish a `pup.phar` and run exactly as they always have: the phar -downloads into the runner's temp directory and runs on the runner's PHP, so it never ends up -in the commit. While every release carries a phar, the other path never runs; the day one does -not, the action calls `stellarwp/pup@v3` — the same way the changelog step calls the -changelogger action — and callers change nothing. +The release itself decides which kind of pup runs, not a version comparison: the action sends a +HEAD request to the release's `pup.phar` asset. Releases up to and including 2.0.0 publish one; the +phar downloads into the runner's temp directory and runs on the runner's PHP. A release that +answers 404 is the TypeScript line, which runs through its own GitHub Action — the same way the +changelog step calls the changelogger action. While every release carries a phar, that path never +runs, and callers change nothing when it starts to. -The manifest spells that call out now, as the contract the pup action is expected to ship: +The manifest carries the call already, as the contract the pup action is expected to ship: ```yaml - uses: stellarwp/pup@v3 @@ -156,20 +151,20 @@ The manifest spells that call out now, as the contract the pup action is expecte command: replace-version --version=4.17.0 ``` -`uses:` takes a static ref, so the floating major tag is the moving pin. Until a phar-less -release exists the branch-version check — the one that refuses to prepare a version lower than -the branch already has — runs on the phar path only; the TS contract does not define it yet. +`uses:` takes a static ref, so the floating major tag is the moving pin. The branch-version check +— the refusal of a version lower than the branch already has — runs on the phar path only; the TS +contract does not define it yet. ## Running it again -Preparing a version the branch already holds changes nothing: the versions match, no TBDs are left and -`changelog/` is empty. The action then commits and pushes nothing, and succeeds, so a retry of a -finished preparation is harmless. +Preparing a version the branch already holds changes nothing: the versions match, no TBDs are left +and `changelog/` is empty. The action commits and pushes nothing and succeeds, so a retry of a +finished preparation is safe. ## The date -The changelogger action writes the date exactly as it is given. `today` would land in the changelog as -the word, and an empty value as an empty date. So the action resolves both to today's date, in UTC, +The changelogger action writes the date exactly as it is given: `today` would land in the changelog +as the word, and an empty value as an empty date. The action resolves both to today's date, in UTC, before the changelog step runs, and that is the date the `date` output reports. ## Pushing @@ -183,44 +178,42 @@ jobs: contents: write ``` -The action pushes with `token` itself, to `HEAD:refs/heads/`, so the checkout can and should use -`persist-credentials: false`. The token reaches git through the environment of that one push, never -on its command line, which the log echoes, and never in `.git/config`, where a later step could read it. -It is masked in the log, together with the encoded credential built from it. +The action pushes with `token` itself, to `HEAD:refs/heads/`, so the checkout can use +`persist-credentials: false`. The token reaches git through the environment of that one push — +never on its command line, which the log would echo, and never in `.git/config`, where a later +step could read it. The token and the base64 credential built from it are masked in the log. -A checkout that keeps its credentials does not get in the way. actions/checkout stores its token under -the same `http./.extraheader` key, and git sends every value of that key, so the push would -carry two Authorization headers and GitHub would refuse it. The action clears that key for its push -before adding its own header, so only `token` is sent. +When the checkout keeps its own credentials, git would send two Authorization headers — its own +and the action's — and GitHub refuses the push with a 400. The action clears the +`http./.extraheader` key before adding its own header, so only `token` is sent. -The push is a fast-forward. If the branch moved after the checkout, the push is refused and the job -fails with git's output, rather than overwriting what landed. +The push is a fast-forward. If the branch moved after the checkout, the push is refused and the +job fails with git's output. -The checkout has to be on `branch` itself, and the action checks that before it changes anything. Pushing -from another branch would also be a fast-forward whenever the release branch is an ancestor of it, -for instance a run started from `main` whose checkout was given no `ref`, and the release would pick -up every commit of that branch. A detached checkout, of a tag or a commit, is refused for the same -reason. +The checkout has to be on `branch` itself, and the action checks that before changing anything. +Pushing from another branch would also fast-forward whenever the release branch is an ancestor of +it — for instance a run started from `main` whose checkout was given no `ref` — and the release +would pick up every commit of that branch. A detached checkout is refused for the same reason. -Pass a PAT or app token as `token` when a ruleset on the release branches blocks `github.token`, or when -a workflow in the repository has to run on the push. GitHub creates no workflow run for a push made -with `GITHUB_TOKEN`. +Pass a PAT or app token as `token` when a ruleset on the release branches blocks `github.token`, +or when a workflow in the repository has to run on the push. GitHub creates no workflow run for a +push made with `GITHUB_TOKEN`. ## How it runs -Two [actions/github-script](https://github.com/actions/github-script) steps run `release-prep.js`, with -the changelogger action between them. The first validates the inputs and the `.puprc`, then runs pup. -The second commits and pushes. github-script supplies the Node runtime and `exec`, so there is no -dependency to install and no bundle to build: the file in the repository is the file that runs. +Two [actions/github-script](https://github.com/actions/github-script) steps run `release-prep.js`, +with the changelogger action between them. The first validates the inputs and the `.puprc`, then +runs pup. The second commits and pushes. github-script supplies the Node runtime and `exec`; there +is no dependency to install and no bundle to build. -The script is loaded by path rather than inlined in the manifest, because github-script's `require` -resolves against the workspace of the repository being built, not this action's directory. The path -comes from `${{ github.action_path }}`. +The script is loaded by path rather than inlined in the manifest because github-script's `require` +resolves against the workspace of the repository being built, not this action's directory. The +path comes from `${{ github.action_path }}`. -Inputs reach the script through `process.env`, never by being interpolated into it, so a version or a -branch name is data and cannot become part of the program. The commands run with their arguments as a -list, without a shell. +Inputs reach the script through `process.env`, never by being interpolated into it, so a version +or a branch name is data and cannot become part of the program. The commands run with their +arguments as a list, without a shell. -`tests/actions/release-prep/release-prep.test.js` covers the rules above. The pup steps run against a -stand-in for `exec`; the commit and push run real git against a local repository. Run `npm install` -once, then `node --test`. +`tests/actions/release-prep/release-prep.test.js` covers the rules above. The pup steps run +against a stand-in for `exec`; the commit and push run real git against a local repository. Run +`npm install` once, then `node --test`. From e6e2d2f8729a182316c0c496745f13b54b5a1473 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Fri, 16 Oct 2026 11:44:01 -0300 Subject: [PATCH 19/27] fix(release-prep): validate the tag of the latest pup release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A response without tag_name would build a download URL for …/download/undefined/pup.phar, whose 404 the distribution check reads as "no phar" — silently routing the run into the TypeScript branch. The tag is now validated against the release-number pattern before it is used, so a broken response fails the run instead of misrouting it. --- .github/actions/release-prep/release-prep.js | 14 +++++++++++++- tests/actions/release-prep/release-prep.test.js | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index 94b413c..3c7113e 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -187,7 +187,19 @@ async function resolvePupRelease({ version, token, fetchImpl }) { ) } - return (await response.json()).tag_name + // The tag goes into a download URL and a temp file path, and a missing one would surface + // as a 404 on …/download/undefined/pup.phar — which the distribution check reads as "no + // phar, this is the TypeScript line". A broken API response must fail here, not misroute + // the run, so the tag is validated before it is used. + const tag = (await response.json()).tag_name + if (typeof tag !== 'string' || !PUP_VERSION_PATTERN.test(tag)) { + throw new Error( + 'The latest pup release has no usable tag. Pass a pinned pup-version and run this step ' + + 'again. Nothing was changed.' + ) + } + + return tag } /** diff --git a/tests/actions/release-prep/release-prep.test.js b/tests/actions/release-prep/release-prep.test.js index 6593eef..edbcf86 100644 --- a/tests/actions/release-prep/release-prep.test.js +++ b/tests/actions/release-prep/release-prep.test.js @@ -693,6 +693,22 @@ describe('release-prep', () => { ) }) + it('fails loudly when the latest release reports no usable tag', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + const fetchImpl = async () => ({ + ok: true, + json: async () => ({ tag_name: undefined }), + }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv({ INPUT_PUP_VERSION: 'latest' }), cwd, fetchImpl }), + /The latest pup release has no usable tag/ + ) + assert.deepEqual(outputs(), {}, 'no distribution is decided from a broken response') + }) + it('does not call the GitHub API for a pinned pup version', async (t) => { const { core } = await coreWithOutputs(t) const { exec } = recordingExec(allSucceed()) From 37e9a2ea0f8c21e0a790997c4de16069ddb1e8b3 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Fri, 16 Oct 2026 17:09:41 -0300 Subject: [PATCH 20/27] fix(release-prep): fail loudly on a phar-less release instead of pre-wiring its steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifest carried the TypeScript-line calls as conditional steps, but a composite action's uses: references resolve when the job is set up, not when the step runs — so the conditional stellarwp/pup@v3 broke every prepare, including phar releases, until that action exists (verified on the test plugins: job setup fails with 'unable to find version v3'). The steps are removed; a release without a pup.phar now fails the prepare step with the pin to use instead, and the README records why the wiring waits for the pup action to exist. --- .github/actions/release-prep/README.md | 11 ++++++----- .github/actions/release-prep/action.yml | 17 ----------------- .github/actions/release-prep/release-prep.js | 17 ++++++++++------- tests/actions/release-prep/release-prep.test.js | 15 ++++++--------- 4 files changed, 22 insertions(+), 38 deletions(-) diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md index c463146..0c5a265 100644 --- a/.github/actions/release-prep/README.md +++ b/.github/actions/release-prep/README.md @@ -139,11 +139,12 @@ spent. The release itself decides which kind of pup runs, not a version comparison: the action sends a HEAD request to the release's `pup.phar` asset. Releases up to and including 2.0.0 publish one; the phar downloads into the runner's temp directory and runs on the runner's PHP. A release that -answers 404 is the TypeScript line, which runs through its own GitHub Action — the same way the -changelog step calls the changelogger action. While every release carries a phar, that path never -runs, and callers change nothing when it starts to. - -The manifest carries the call already, as the contract the pup action is expected to ship: +answers 404 is the TypeScript line, which is meant to run through its own GitHub Action — and the +prepare step fails there, saying so and naming the pin to use instead. The manifest cannot carry +that call behind a condition: a composite action's `uses:` references resolve when the job is set +up, not when the step runs, so a conditional reference to an action that does not exist yet would +break every run. When the TypeScript line publishes its action, the call is wired in as the +changelogger's is — one `uses:` step per pup command, `command:` as the input: ```yaml - uses: stellarwp/pup@v3 diff --git a/.github/actions/release-prep/action.yml b/.github/actions/release-prep/action.yml index 42249d6..1f5653f 100644 --- a/.github/actions/release-prep/action.yml +++ b/.github/actions/release-prep/action.yml @@ -82,23 +82,6 @@ runs: const { runPrepare } = require(`${process.env.ACTION_PATH}/release-prep.js`) await runPrepare({ core, exec, env: process.env }) - # The TypeScript line of pup publishes no .phar; its release runs through its own GitHub - # Action, called here the way the changelog step calls the changelogger. These steps run - # only when the prepare step resolved a release without a phar asset, so they stay inert - # until such a release exists — and spell out the interface the pup action is expected to - # ship. uses: takes a static ref, so the floating major tag is the moving pin. - - name: Bump the version (pup) - if: ${{ steps.prepare.outputs.distribution == 'ts' }} - uses: stellarwp/pup@v3 - with: - command: replace-version --version=${{ steps.prepare.outputs.version }} - - - name: Replace the TBDs (pup) - if: ${{ steps.prepare.outputs.distribution == 'ts' }} - uses: stellarwp/pup@v3 - with: - command: replace-tbd --version=${{ steps.prepare.outputs.version }} - # The resolved date, never inputs.date: this action writes the date it is given as it is, so # `today` would land in the changelog as the word and an empty value as an empty date. - name: Write the changelog diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index 3c7113e..b8701cf 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -542,13 +542,16 @@ async function runPrepare({ core, exec, env, cwd = process.cwd(), fetchImpl = gl if (distribution === 'phar') { await runPrepareWithPhar({ core, exec, cwd, env, pharRelease: pupRelease, version, branch }) } else { - // The TypeScript line: the release publishes no phar and runs through its own GitHub - // Action instead. The action manifest carries the calls, conditioned on the distribution - // output this step sets; here only the inputs are validated. The branch-version check - // below is part of the phar path — the TS contract does not define it yet. - core.info( - `pup ${pupRelease} publishes no .phar asset; the preparation continues with the release's ` + - 'own GitHub Action (stellarwp/pup@v3). The branch-version check only runs on the phar path.' + // The TypeScript line publishes no phar and would run through its own GitHub Action — but a + // composite action's `uses:` references resolve when the JOB is set up, not when the step + // runs, so the manifest cannot carry those calls behind a condition: they would break every + // run until the pup action exists. Until it does, a phar-less release fails here, loudly, + // with the way out. The branch-version check below is part of the phar path — the TS + // contract does not define it yet. + throw new Error( + `pup ${pupRelease} publishes no .phar asset, and the TypeScript distribution is not ` + + 'callable from this action yet. Pin pup-version to a release that ships a .phar, such ' + + 'as 2.0.0. Nothing was changed.' ) } diff --git a/tests/actions/release-prep/release-prep.test.js b/tests/actions/release-prep/release-prep.test.js index edbcf86..8c79d17 100644 --- a/tests/actions/release-prep/release-prep.test.js +++ b/tests/actions/release-prep/release-prep.test.js @@ -724,7 +724,7 @@ describe('release-prep', () => { assert.equal(called, 0, 'a pinned version downloads straight from the release URL') }) - it('hands the preparation to the release action when the release has no phar', async (t) => { + it('fails loudly when the release has no phar, pointing at the pin', async (t) => { const { core, outputs } = await coreWithOutputs(t) const { exec, calls } = recordingExec( allSucceed([ @@ -733,14 +733,11 @@ describe('release-prep', () => { ) const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) - await runPrepare({ core, exec, env: prepareEnv(), cwd }) - - assert.deepEqual(outputs(), { - version: '4.17.0', - date: TODAY, - branch: 'release/M26.jigglypuff', - distribution: 'ts', - }) + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv(), cwd }), + /pup 2\.0\.0 publishes no \.phar asset.*Pin pup-version to a release that ships a \.phar/s + ) + assert.deepEqual(outputs(), { distribution: 'ts' }, 'the resolved distribution is reported on the failed run') assert.ok( !sequence(calls).some((call) => /fsSL|php /.test(call)), 'no phar is downloaded and no PHP runs without a phar asset' From bc202ae2847129bb2cc7af6ebe542c616e1f498f Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Fri, 16 Oct 2026 17:53:30 -0300 Subject: [PATCH 21/27] chore(release-prep): rename the template's dispatch input from ref to branch One name for one concept: the action's input is branch, and the workflow a release tool dispatches now says branch too. The checkout keeps its own ref input, fed from inputs.branch. --- .github/actions/release-prep/README.md | 4 ++-- templates/workflows/release-prep.yml | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md index 0c5a265..fe8e595 100644 --- a/.github/actions/release-prep/README.md +++ b/.github/actions/release-prep/README.md @@ -6,12 +6,12 @@ changelog, then commits and pushes the result to the branch: ```yaml - uses: actions/checkout@v6 with: - ref: ${{ inputs.ref }} + ref: ${{ inputs.branch }} persist-credentials: false - uses: stellarwp/plugin-toolbox/.github/actions/release-prep@v1 with: - branch: ${{ inputs.ref }} + branch: ${{ inputs.branch }} version: ${{ inputs.version }} ``` diff --git a/templates/workflows/release-prep.yml b/templates/workflows/release-prep.yml index ee2809b..a77476a 100644 --- a/templates/workflows/release-prep.yml +++ b/templates/workflows/release-prep.yml @@ -7,17 +7,17 @@ name: Release Prep # A file to copy into .github/workflows/ of a plugin repository, not a workflow to call. The # repository needs a .puprc and a changelogger config first; see the release-prep action's README. # -# The inputs are the interface a release tool dispatches: `ref` and `version`, and an optional -# `date` for runs started by hand. Keep `date` optional: a tool that sends only `ref` and `version` -# refuses a workflow that requires more. +# The inputs are the interface a release tool dispatches: `branch` and `version`, and an +# optional `date` for runs started by hand. Keep `date` optional: a tool that sends only `branch` +# and `version` refuses a workflow that requires more. # # Run it locally: you cannot, it pushes to a branch. Start it by hand with -# `gh workflow run release-prep.yml -f ref=release/M26.jigglypuff -f version=4.17.0`. +# `gh workflow run release-prep.yml -f branch=release/M26.jigglypuff -f version=4.17.0`. on: workflow_dispatch: inputs: - ref: + branch: description: 'Release branch to prepare and push to, e.g. release/M26.jigglypuff' required: true type: string @@ -34,7 +34,7 @@ on: # Two preparations of the same branch would both commit on top of the same checkout, and the second # push would be refused. One group per branch makes the second wait for the first. concurrency: - group: ${{ github.workflow }}-${{ inputs.ref }} + group: ${{ github.workflow }}-${{ inputs.branch }} permissions: contents: read @@ -48,7 +48,7 @@ jobs: steps: - uses: actions/checkout@v6 with: - ref: ${{ inputs.ref }} + ref: ${{ inputs.branch }} # The action pushes with the token it is given, so the checkout keeps no credentials in # .git/config for a later step to find. persist-credentials: false @@ -58,6 +58,6 @@ jobs: - uses: stellarwp/plugin-toolbox/.github/actions/release-prep@v1 with: - branch: ${{ inputs.ref }} + branch: ${{ inputs.branch }} version: ${{ inputs.version }} date: ${{ inputs.date }} From f8efcd9a9fac7c7855fe02896339e02ea0160ba3 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Tue, 20 Oct 2026 15:57:50 -0300 Subject: [PATCH 22/27] docs(release-prep): remove evaluative filler from the README and the TS comment "The floating major tag is the moving pin" and "fails here, loudly, with the way out" made the reader work out what the sentence meant. Both now state the fact: `@v3` resolves to the newest v3.x release, and a phar-less release fails with an error that names the pin to use. Refs: https://github.com/stellarwp/plugin-toolbox/pull/7#discussion_r4325280021 Refs: https://github.com/stellarwp/plugin-toolbox/pull/7#discussion_r4325292054 --- .github/actions/release-prep/README.md | 6 +++--- .github/actions/release-prep/release-prep.js | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md index fe8e595..3b5d341 100644 --- a/.github/actions/release-prep/README.md +++ b/.github/actions/release-prep/README.md @@ -152,9 +152,9 @@ changelogger's is — one `uses:` step per pup command, `command:` as the input: command: replace-version --version=4.17.0 ``` -`uses:` takes a static ref, so the floating major tag is the moving pin. The branch-version check -— the refusal of a version lower than the branch already has — runs on the phar path only; the TS -contract does not define it yet. +`uses:` takes a static ref, so `@v3` always resolves to the newest v3.x release of pup. The +branch-version check — the refusal of a version lower than the branch already has — runs on the +phar path only; the TS contract does not define it yet. ## Running it again diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index b8701cf..512fe4e 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -545,9 +545,9 @@ async function runPrepare({ core, exec, env, cwd = process.cwd(), fetchImpl = gl // The TypeScript line publishes no phar and would run through its own GitHub Action — but a // composite action's `uses:` references resolve when the JOB is set up, not when the step // runs, so the manifest cannot carry those calls behind a condition: they would break every - // run until the pup action exists. Until it does, a phar-less release fails here, loudly, - // with the way out. The branch-version check below is part of the phar path — the TS - // contract does not define it yet. + // run until the pup action exists. Until it does, a phar-less release fails here with an + // error naming the pin to use instead. The branch-version check below is part of the phar + // path — the TS contract does not define it yet. throw new Error( `pup ${pupRelease} publishes no .phar asset, and the TypeScript distribution is not ` + 'callable from this action yet. Pin pup-version to a release that ships a .phar, such ' + From 5743a702547f88a207d17d65ac6acf94562f5069 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Tue, 20 Oct 2026 15:58:40 -0300 Subject: [PATCH 23/27] docs(release-prep): say which pup releases ship a phar and what happens without one The pup-version description promised that a phar-less release would run through pup's own GitHub Action. There is no such logic: the prepare step fails and names a pin to use instead. The README and the JSDoc of resolvePupDistribution also said every release up to 2.0.0 ships a phar, which the published releases contradict: 1.2.3 and 0.0.1 have none. The three now describe what the action does and which releases it can run. Refs: https://github.com/stellarwp/plugin-toolbox/pull/7#discussion_r4325357880 --- .github/actions/release-prep/README.md | 19 ++++++++++--------- .github/actions/release-prep/action.yml | 5 +++-- .github/actions/release-prep/release-prep.js | 4 ++-- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md index 3b5d341..193d011 100644 --- a/.github/actions/release-prep/README.md +++ b/.github/actions/release-prep/README.md @@ -136,15 +136,16 @@ pinned release downloads straight from its release page. `latest` reads the newe the GitHub API, authenticated with `token` so the runner's shared unauthenticated quota is not spent. -The release itself decides which kind of pup runs, not a version comparison: the action sends a -HEAD request to the release's `pup.phar` asset. Releases up to and including 2.0.0 publish one; the -phar downloads into the runner's temp directory and runs on the runner's PHP. A release that -answers 404 is the TypeScript line, which is meant to run through its own GitHub Action — and the -prepare step fails there, saying so and naming the pin to use instead. The manifest cannot carry -that call behind a condition: a composite action's `uses:` references resolve when the job is set -up, not when the step runs, so a conditional reference to an action that does not exist yet would -break every run. When the TypeScript line publishes its action, the call is wired in as the -changelogger's is — one `uses:` step per pup command, `command:` as the input: +The release itself decides whether the action can run it, not a version comparison: the action +sends a HEAD request to the release's `pup.phar` asset. Every release from 1.2.4 to 2.0.0 +publishes one; the phar downloads into the runner's temp directory and runs on the runner's PHP. +A release without one is not supported yet: the prepare step fails, saying so and naming a pin +to use instead. The TypeScript line of pup publishes no phar and is meant to run through its own +GitHub Action. The manifest cannot carry that call behind a condition: a composite action's +`uses:` references resolve when the job is set up, not when the step runs, so a conditional +reference to an action that does not exist yet would break every run. When the TypeScript line +publishes its action, the call is wired in as the changelogger's is — one `uses:` step per pup +command, `command:` as the input: ```yaml - uses: stellarwp/pup@v3 diff --git a/.github/actions/release-prep/action.yml b/.github/actions/release-prep/action.yml index 1f5653f..d62a3cd 100644 --- a/.github/actions/release-prep/action.yml +++ b/.github/actions/release-prep/action.yml @@ -38,8 +38,9 @@ inputs: pup-version: description: >- The pup release that bumps the version and replaces the TBDs: a release number, such as - 2.0.0, or 'latest' for the newest release. Versions up to 2.0.0 ship as a .phar; once pup - publishes a release without one, the action runs that release's own GitHub Action instead. + 2.0.0, or 'latest' for the newest release. It has to ship a pup.phar asset, which every + release from 1.2.4 to 2.0.0 does. A release without one is not supported yet: the action + fails and names a pin to use instead. required: false default: 'latest' diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index 512fe4e..70a473c 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -280,8 +280,8 @@ function pupDownloadUrl(version) { /** * Which distribution a pup release carries, decided by looking at the release itself: a `pup.phar` - * asset means the PHP build the action has always run, and its absence means the release expects - * to run through its own GitHub Action (the TypeScript line, once it publishes one). + * asset means the PHP build the action runs. A release without one (1.2.3 and 0.0.1 among the + * published ones, and the TypeScript line once it ships) cannot be run from here yet. * * The check is a HEAD request against the asset URL, not the releases API, so a pinned * `pup-version` stays free of API calls and rate limits. From 0ba83199f6feafafff42e5e5e3936fe8cb049355 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Tue, 20 Oct 2026 16:00:29 -0300 Subject: [PATCH 24/27] fix(release-prep): expose the distribution output and tell a missing pup release from a phar-less one The prepare step set a `distribution` output that the manifest never declared, so no caller could read it. It is declared now, described as what it is on a run that succeeds: `phar`. A pinned pup-version that does not exist, a typo for instance, got the same 404 on the phar asset as a release that exists without one, and the error blamed the missing phar. A second HEAD request, to the release page, tells the two apart: a missing release fails with "is not a release: check pup-version for a typo", and a release without a phar keeps its own message, now without claiming it is the TypeScript line (pup 1.2.3 and 0.0.1 are published releases without one). A pinned release still makes no API call. Refs: https://github.com/stellarwp/plugin-toolbox/pull/7#discussion_r4325389775 Refs: https://github.com/stellarwp/plugin-toolbox/pull/7#discussion_r4325400963 --- .github/actions/release-prep/README.md | 5 +- .github/actions/release-prep/action.yml | 5 + .github/actions/release-prep/release-prep.js | 97 +++++++++++++++---- .../actions/release-prep/release-prep.test.js | 55 ++++++++++- 4 files changed, 137 insertions(+), 25 deletions(-) diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md index 193d011..f7f5807 100644 --- a/.github/actions/release-prep/README.md +++ b/.github/actions/release-prep/README.md @@ -128,6 +128,7 @@ that is not a valid branch name, and a checkout that is not on `branch`. | `date` | `2026-10-08` | The date written in the changelog | | `commit` | `3f2a…` | The commit pushed to the branch. Empty when nothing changed | | `files` | `my-plugin.php` | The files the commit changed, one per line. Empty when nothing changed | +| `distribution` | `phar` | Which pup distribution the release carries. `phar` on every run that succeeds, the only distribution the action runs today | ## Which pup runs @@ -140,7 +141,9 @@ The release itself decides whether the action can run it, not a version comparis sends a HEAD request to the release's `pup.phar` asset. Every release from 1.2.4 to 2.0.0 publishes one; the phar downloads into the runner's temp directory and runs on the runner's PHP. A release without one is not supported yet: the prepare step fails, saying so and naming a pin -to use instead. The TypeScript line of pup publishes no phar and is meant to run through its own +to use instead. A pinned release that does not exist, such as a mistyped number, fails the step +with "is not a release"; the action tells the two apart with a second HEAD request, to the +release's page. The TypeScript line of pup publishes no phar and is meant to run through its own GitHub Action. The manifest cannot carry that call behind a condition: a composite action's `uses:` references resolve when the job is set up, not when the step runs, so a conditional reference to an action that does not exist yet would break every run. When the TypeScript line diff --git a/.github/actions/release-prep/action.yml b/.github/actions/release-prep/action.yml index d62a3cd..fb59b42 100644 --- a/.github/actions/release-prep/action.yml +++ b/.github/actions/release-prep/action.yml @@ -57,6 +57,11 @@ outputs: files: description: 'The files the commit changed, one per line. Empty when nothing changed.' value: ${{ steps.commit.outputs.files }} + distribution: + description: >- + Which pup distribution the release carries. On a run that succeeds it is `phar`, the only + distribution the action runs today. + value: ${{ steps.prepare.outputs.distribution }} runs: using: 'composite' diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index 70a473c..799b4ff 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -188,9 +188,9 @@ async function resolvePupRelease({ version, token, fetchImpl }) { } // The tag goes into a download URL and a temp file path, and a missing one would surface - // as a 404 on …/download/undefined/pup.phar — which the distribution check reads as "no - // phar, this is the TypeScript line". A broken API response must fail here, not misroute - // the run, so the tag is validated before it is used. + // as a 404 on …/download/undefined/pup.phar, which the distribution check would report as + // a release that does not exist. A broken API response must fail here, naming the API, so + // the tag is validated before it is used. const tag = (await response.json()).tag_name if (typeof tag !== 'string' || !PUP_VERSION_PATTERN.test(tag)) { throw new Error( @@ -278,40 +278,95 @@ function pupDownloadUrl(version) { return `https://github.com/stellarwp/pup/releases/download/${version}/pup.phar` } +/** + * The page of a pup release, which answers 404 when the release does not exist. + * + * @param {string} version A pup release number, already validated. + * + * @returns {string} The release page URL. + */ +function pupReleaseUrl(version) { + return `https://github.com/stellarwp/pup/releases/tag/${version}` +} + +/** + * Sends a HEAD request and reports whether the URL exists. + * + * @param {object} exec The @actions/exec toolkit. + * @param {string} url The URL to check. + * + * @returns {Promise<{exists: boolean, missing: boolean, result: object}>} Whether it answered + * 200, whether it answered 404, and curl's result for anything else. + */ +async function headExists(exec, url) { + const result = await exec.getExecOutput('curl', ['-fsSI', url], { + ignoreReturnCode: true, + silent: true, + }) + + // curl -f answers a 404 with exit code 22 and the status in stderr. + return { + exists: result.exitCode === 0, + missing: result.exitCode === 22 && /404/.test(result.stderr), + result, + } +} + /** * Which distribution a pup release carries, decided by looking at the release itself: a `pup.phar` * asset means the PHP build the action runs. A release without one (1.2.3 and 0.0.1 among the * published ones, and the TypeScript line once it ships) cannot be run from here yet. * - * The check is a HEAD request against the asset URL, not the releases API, so a pinned - * `pup-version` stays free of API calls and rate limits. + * The checks are HEAD requests against the asset URL and, when that is missing, the release page, + * not the releases API, so a pinned `pup-version` stays free of API calls and rate limits. The + * second request tells a release without a phar from a release that does not exist, which a + * mistyped pin produces. * * @param {object} exec The @actions/exec toolkit. * @param {string} release The resolved pup release tag. * - * @throws {Error} When the check itself fails; the run cannot tell which path to take. + * @throws {Error} When the release does not exist, or a check itself fails and the run cannot + * tell which path to take. * * @returns {Promise<'phar'|'ts'>} The distribution the release carries. */ async function resolvePupDistribution({ exec, release }) { - const head = await exec.getExecOutput('curl', ['-fsSI', pupDownloadUrl(release)], { - ignoreReturnCode: true, - silent: true, - }) + const asset = await headExists(exec, pupDownloadUrl(release)) - if (head.exitCode === 0) { + if (asset.exists) { return 'phar' } - // curl -f answers a 404 with exit code 22 and the status in stderr. - if (head.exitCode === 22 && /404/.test(head.stderr)) { - return 'ts' + if (asset.missing) { + const page = await headExists(exec, pupReleaseUrl(release)) + + if (page.exists) { + return 'ts' + } + + if (page.missing) { + throw new Error(`pup ${release} is not a release: check pup-version for a typo. Nothing was changed.`) + } + + throw checkFailed(release, page.result) } - const output = [head.stdout, head.stderr].map((text) => text.trim()).filter(Boolean) - throw new Error( - `Checking the pup ${release} release failed (exit code ${head.exitCode}):\n` + - output.join('\n') + throw checkFailed(release, asset.result) +} + +/** + * The error for a release check that neither succeeded nor answered 404. + * + * @param {string} release The pup release tag. + * @param {object} result What curl returned. + * + * @returns {Error} The error, holding what curl printed. + */ +function checkFailed(release, result) { + const output = [result.stdout, result.stderr].map((text) => text.trim()).filter(Boolean) + + return new Error( + `Checking the pup ${release} release failed (exit code ${result.exitCode}):\n` + output.join('\n') ) } @@ -549,9 +604,8 @@ async function runPrepare({ core, exec, env, cwd = process.cwd(), fetchImpl = gl // error naming the pin to use instead. The branch-version check below is part of the phar // path — the TS contract does not define it yet. throw new Error( - `pup ${pupRelease} publishes no .phar asset, and the TypeScript distribution is not ` + - 'callable from this action yet. Pin pup-version to a release that ships a .phar, such ' + - 'as 2.0.0. Nothing was changed.' + `pup ${pupRelease} publishes no pup.phar asset, so this action cannot run it. Pin ` + + 'pup-version to a release that ships one, such as 2.0.0. Nothing was changed.' ) } @@ -714,6 +768,7 @@ module.exports = { readPuprc, hasChangeloggerConfig, pupDownloadUrl, + pupReleaseUrl, pupCommands, basicCredential, pushEnv, diff --git a/tests/actions/release-prep/release-prep.test.js b/tests/actions/release-prep/release-prep.test.js index 8c79d17..cb5fca5 100644 --- a/tests/actions/release-prep/release-prep.test.js +++ b/tests/actions/release-prep/release-prep.test.js @@ -16,6 +16,7 @@ const { readPuprc, hasChangeloggerConfig, pupDownloadUrl, + pupReleaseUrl, pupCommands, basicCredential, pushEnv, @@ -323,6 +324,10 @@ describe('release-prep', () => { ) }) + it('checks the release page when the phar is missing', () => { + assert.equal(pupReleaseUrl('2.0.0'), 'https://github.com/stellarwp/pup/releases/tag/2.0.0') + }) + it('bumps the version files, then replaces the TBDs', () => { assert.deepEqual(pupCommands('/tmp/pup.phar', '4.17.0'), [ { command: 'php', args: ['/tmp/pup.phar', 'replace-version', '4.17.0'] }, @@ -724,26 +729,70 @@ describe('release-prep', () => { assert.equal(called, 0, 'a pinned version downloads straight from the release URL') }) - it('fails loudly when the release has no phar, pointing at the pin', async (t) => { + it('fails loudly when the release exists but has no phar, pointing at the pin', async (t) => { + // pup 1.2.3 is such a release today; the TypeScript line will be another. const { core, outputs } = await coreWithOutputs(t) const { exec, calls } = recordingExec( allSucceed([ - { command: 'curl', args: /fsSI/, exitCode: 22, stderr: 'The requested URL returned error: 404' }, + { command: 'curl', args: /fsSI .*\/download\//, exitCode: 22, stderr: 'The requested URL returned error: 404' }, + { command: 'curl', args: /fsSI .*\/releases\/tag\// }, ]) ) const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) await assert.rejects( runPrepare({ core, exec, env: prepareEnv(), cwd }), - /pup 2\.0\.0 publishes no \.phar asset.*Pin pup-version to a release that ships a \.phar/s + /pup 2\.0\.0 publishes no pup\.phar asset.*Pin pup-version to a release that ships one, such as 2\.0\.0/s ) assert.deepEqual(outputs(), { distribution: 'ts' }, 'the resolved distribution is reported on the failed run') + assert.deepEqual( + sequence(calls).filter((call) => call.startsWith('curl')), + [ + 'curl -fsSI https://github.com/stellarwp/pup/releases/download/2.0.0/pup.phar', + 'curl -fsSI https://github.com/stellarwp/pup/releases/tag/2.0.0', + ], + 'the release page is checked once the asset is missing' + ) assert.ok( !sequence(calls).some((call) => /fsSL|php /.test(call)), 'no phar is downloaded and no PHP runs without a phar asset' ) }) + it('fails when the pinned release does not exist, naming the pin', async (t) => { + // A typo in pup-version gets a 404 on the asset too; the release page tells the two apart. + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([ + { command: 'curl', args: /fsSI/, exitCode: 22, stderr: 'The requested URL returned error: 404' }, + ]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv({ INPUT_PUP_VERSION: '2.0.9' }), cwd }), + /pup 2\.0\.9 is not a release: check pup-version for a typo\. Nothing was changed\./ + ) + assert.deepEqual(outputs(), {}, 'no distribution is reported for a release that does not exist') + assert.ok(!sequence(calls).some((call) => /fsSL|php /.test(call))) + }) + + it('fails loudly when the release page check itself errors', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec } = recordingExec( + allSucceed([ + { command: 'curl', args: /fsSI .*\/download\//, exitCode: 22, stderr: 'The requested URL returned error: 404' }, + { command: 'curl', args: /fsSI .*\/releases\/tag\//, exitCode: 28, stderr: 'Connection timed out' }, + ]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv(), cwd }), + /Checking the pup 2\.0\.0 release failed.*Connection timed out/s + ) + }) + it('fails loudly when the phar check itself errors', async (t) => { const { core } = await coreWithOutputs(t) const { exec } = recordingExec( From 3e7fcf57c0482a07735d08101b058452b2f53056 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Tue, 20 Oct 2026 16:04:23 -0300 Subject: [PATCH 25/27] feat(release-prep): validate the version against the configured changelogger strategy The action applied the stellarwp shape, three or four numeric parts, to every repository, while the changelogger's default is semver and a repository only gets stellarwp by setting changelogger.versioning. On a semver repository 4.17.0.1 passed the action, pup bumped the files, and the changelog step then failed on the version, leaving a half-prepared working tree; 4.17.0-beta.1 was refused although the changelog step would take it. readChangeloggerStrategy reads changelogger.versioning from package.json the way the changelogger does: no section, no file or invalid JSON mean semver; a .js or .ts file is a custom strategy; any other value fails here, since the changelogger fails on it before it looks at the changes. validateVersion checks the stellarwp shape or a semver.org version accordingly, and leaves a custom strategy to the changelog step with a warning. A leading v is refused under semver even though the semver package takes it: the version goes into the version files and the tag as given. The branch-version comparison reads the numeric start of the version, so 4.17.0-beta.1 counts as 4.17.0, and a version that starts with no number only warns. The commit step checks only that its version, the prepare step's validated output, is not empty. The README, the version input's description and the JSDoc of the stellarwp pattern describe the per-strategy rule. Refs: https://github.com/stellarwp/plugin-toolbox/pull/7#discussion_r4325287629 Refs: https://github.com/stellarwp/plugin-toolbox/pull/7#discussion_r4325342468 Refs: https://github.com/stellarwp/plugin-toolbox/pull/7#discussion_r4325418142 --- .github/actions/release-prep/README.md | 34 ++-- .github/actions/release-prep/action.yml | 8 +- .github/actions/release-prep/release-prep.js | 151 ++++++++++++--- .../actions/release-prep/release-prep.test.js | 176 ++++++++++++++++-- 4 files changed, 310 insertions(+), 59 deletions(-) diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md index f7f5807..bdee931 100644 --- a/.github/actions/release-prep/README.md +++ b/.github/actions/release-prep/README.md @@ -79,33 +79,43 @@ Two gaps only produce a warning; the run continues: - No `checks.tbd.dirs`: pup falls back to its default and replaces TBDs under `src/` only. - No `changelogger` section: the changelogger uses its defaults — a keepachangelog `changelog.md` - with semver versioning. + with semver versioning — and the action validates the version as semver. A writing strategy given as a `.js` file is loaded from the checkout; it has to be there before the action runs. One written in TypeScript has to be built in an earlier step. ## Versions it refuses -The version has to be three or four numeric parts, such as `4.17.0` or `4.17.0.1`. That is the shape -the changelogger's default `stellarwp` versioning accepts. A repository with a custom changelogger -version strategy may accept other shapes, but this action still takes three or four numeric parts. -The changelog step would fail on anything else, so the action refuses it before pup has touched -anything. +The version is checked against the versioning strategy the repository's changelogger configures, +`changelogger.versioning` in `package.json`, so the action refuses what the changelog step would +refuse, before pup has touched anything: + +- `stellarwp`: 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` is refused, even though it is a valid tag name. +- `semver`, or no `changelogger` section: `MAJOR.MINOR.PATCH` with an optional pre-release, such as + `4.17.0` or `4.17.0-beta.1`. A fourth part, `4.17.0.1`, is refused. So is a leading `v`: the + changelogger's semver check would take `v4.17.0`, but the version goes into the version files + and the tag as it is given. +- A custom strategy, `versioning` set to a `.js` or `.ts` file: no shape check. The changelog step + validates the version with that strategy, and the action warns that it did not. +- Any other `versioning` value fails the action, because the changelog step would fail on it. + +The changelog step validates the version only when `changelog/` holds entries to write; without +any, it writes nothing and accepts any version. The action's check does not depend on that. - **An empty version is refused.** A tool that dispatches the workflow for a release with no version set sends an empty one. When the workflow declares `version` as required, as the template does, GitHub refuses the dispatch itself with HTTP 422 and no run starts. When the workflow declares it optional, the run starts and the action fails it with "No version was given". Either way the release needs a version before the step runs again. -- **A pre-release is refused.** `4.17.0-beta.1` is rejected by the `stellarwp` versioning, so the - action cannot prepare one, even though it is a valid tag name. A custom version strategy that - accepts pre-releases does not change this. - **A version lower than the branch's is refused.** pup reads the version the branch already has (`pup get-version`, the first file in `paths.versions`), and preparing `4.17.0` on a branch at `4.18.0` would bump the plugin backwards: either the version or the branch is wrong. The same - version is accepted; that is what re-running a preparation finds. When the version files hold - something that is not a numeric version, such as `dev`, the job only warns. When pup cannot find a - version at all, it fails, as `replace-version` would. + version is accepted; that is what re-running a preparation finds. Only the numeric start of the + version is compared: `4.17.0-beta.1` counts as `4.17.0`, so it is accepted on a branch at `4.17.0` + and refused on one at `4.18.0`. When the version files hold something that is not a numeric + version, such as `dev`, or the version itself does not start with a number, the job only warns. + When pup cannot find a version at all, it fails, as `replace-version` would. Each refusal happens before anything is changed. So do a `date` that is not `YYYY-MM-DD`, a `branch` that is not a valid branch name, and a checkout that is not on `branch`. diff --git a/.github/actions/release-prep/action.yml b/.github/actions/release-prep/action.yml index fb59b42..963031c 100644 --- a/.github/actions/release-prep/action.yml +++ b/.github/actions/release-prep/action.yml @@ -17,10 +17,10 @@ inputs: required: true version: description: >- - The version to prepare: three or four numeric parts, such as `4.17.0` or `4.17.0.1`. That is - the shape the default `stellarwp` changelogger versioning takes; a custom version strategy - does not widen what the action accepts. An empty value and a version lower than the one the - branch already has are refused before anything is changed. + The version to prepare. It is validated against the versioning strategy the repository's + changelogger configures: three or four numeric parts for `stellarwp`, a semver version for + `semver` or no configuration, no shape check for a custom strategy. An empty value and a + version lower than the one the branch already has are refused before anything is changed. required: true date: description: >- diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index 799b4ff..75ae21d 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -17,11 +17,23 @@ const path = require('node:path') const { compareVersions } = require('../../lib/versions.js') /** - * Three or four numeric parts. It is what the stellarwp changelog versioning accepts, minus its - * blind spot: it reads an empty part as 0, so it would take 2.1..21. A version of this shape is - * also always a valid tag name. + * 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 VERSION_PATTERN = /^\d+\.\d+\.\d+(\.\d+)?$/ +const STELLARWP_VERSION_PATTERN = /^\d+\.\d+\.\d+(\.\d+)?$/ + +/** + * A semver version as semver.org defines it: MAJOR.MINOR.PATCH, an optional pre-release and + * optional build metadata. The changelogger's semver strategy validates with the semver package, + * which also takes a leading `v`; this pattern does not, because the version goes into the version + * files and the tag as it is given. + */ +const SEMVER_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?:[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ + +/** The numeric start of a version, which is what the branch-version comparison reads. */ +const NUMERIC_PREFIX = /^\d+(\.\d+)*/ /** A pup release number, which goes into the download URL. */ const PUP_VERSION_PATTERN = /^\d+\.\d+\.\d+$/ @@ -36,15 +48,15 @@ const COMMIT_AUTHOR = { } /** - * Checks the version to prepare. + * Reads the version input, refusing an empty one. * * @param {string} version The `version` input. * - * @throws {Error} When it is empty, or not three or four numeric parts. + * @throws {Error} When it is empty. * * @returns {string} The version, without surrounding whitespace. */ -function validateVersion(version) { +function readVersion(version) { const trimmed = String(version ?? '').trim() if (trimmed === '') { @@ -54,7 +66,25 @@ function validateVersion(version) { ) } - if (!VERSION_PATTERN.test(trimmed)) { + return trimmed +} + +/** + * Checks the version to prepare against the versioning strategy the repository's changelogger + * configures, so the action refuses what the changelog step would refuse, before pup has run. + * + * @param {string} version The `version` input. + * @param {'stellarwp'|'semver'|'custom'} strategy The strategy readChangeloggerStrategy read. + * + * @throws {Error} When it is empty, or not a version the strategy accepts. A custom strategy + * validates on its own, in the changelog step. + * + * @returns {string} The version, without surrounding whitespace. + */ +function validateVersion(version, strategy) { + const trimmed = readVersion(version) + + if (strategy === 'stellarwp' && !STELLARWP_VERSION_PATTERN.test(trimmed)) { throw new Error( `'${trimmed}' is not a version this action can prepare. It takes 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 is not supported, ' + @@ -62,6 +92,14 @@ function validateVersion(version) { ) } + if (strategy === 'semver' && !SEMVER_PATTERN.test(trimmed)) { + throw new Error( + `'${trimmed}' is not a version this action can prepare. The changelogger validates it as ` + + 'semver: MAJOR.MINOR.PATCH with an optional pre-release, such as 4.17.0 or 4.17.0-beta.1, ' + + 'and no leading v. Nothing was changed.' + ) + } + return trimmed } @@ -248,23 +286,51 @@ function readPuprc(text) { } /** - * Whether package.json configures the changelogger. Without that section the changelogger falls - * back to its defaults, which write a keepachangelog `changelog.md` with semver versioning. + * Reads which versioning strategy the changelogger will validate the version with. + * + * The changelogger reads `changelogger.versioning` from package.json and falls back to `semver` + * without the section, without the file, or when the file is not JSON; it loads a `.js` or `.ts` + * value as a custom strategy with its own validation, and fails on any other value before it looks + * at the changes, so that case fails here, before pup has run. * * @param {string|null} text The contents of package.json, or null when there is none. * - * @returns {boolean} True when it has a `changelogger` section. + * @throws {Error} When `versioning` is a value the changelogger does not know. + * + * @returns {{strategy: 'stellarwp'|'semver'|'custom', versioning: string, configured: boolean}} + * The strategy, the `versioning` value it came from, and whether package.json has a + * `changelogger` section at all. */ -function hasChangeloggerConfig(text) { - if (text === null) { - return false +function readChangeloggerStrategy(text) { + let section = null + + if (text !== null) { + try { + section = JSON.parse(text)?.changelogger ?? null + } catch { + section = null + } } - try { - return Boolean(JSON.parse(text)?.changelogger) - } catch { - return false + if (!section) { + return { strategy: 'semver', versioning: 'semver', configured: false } + } + + const versioning = (typeof section === 'object' ? section.versioning : undefined) ?? 'semver' + + if (versioning === 'stellarwp' || versioning === 'semver') { + return { strategy: versioning, versioning, configured: true } + } + + if (typeof versioning === 'string' && /\.(js|ts)$/.test(versioning)) { + return { strategy: 'custom', versioning, configured: true } } + + throw new Error( + `package.json sets changelogger.versioning to '${versioning}', which is not a versioning ` + + 'strategy the changelogger knows: use stellarwp, semver, or a .js or .ts file. The changelog ' + + 'step would fail on it. Nothing was changed.' + ) } /** @@ -469,7 +535,18 @@ function lastLine(output) { * @returns {string|null} The version, or null when the line holds no numeric version, such as `dev`. */ function readCurrentVersion(output) { - const match = /^\d+(\.\d+)*/.exec(lastLine(output)) + return numericPrefix(lastLine(output)) +} + +/** + * The numeric start of a version: 4.17.0 for 4.17.0-beta.1, the whole of 4.17.0.1. + * + * @param {string} version A version. + * + * @returns {string|null} The numeric start, or null when the version does not start with a number. + */ +function numericPrefix(version) { + const match = NUMERIC_PREFIX.exec(version) return match ? match[0] : null } @@ -525,8 +602,10 @@ async function runOrFail(exec, command, args, options, what) { * Validates the inputs, then bumps the version and replaces the TBDs with pup. * * Every check runs before anything is written, so a wrong version, date or ref, a checkout that is - * not on the release branch, a repository without a usable .puprc, or a version lower than the one - * the branch already has, fails with the branch untouched. + * not on the release branch, a repository without a usable .puprc or with a changelogger versioning + * the changelogger does not know, or a version lower than the one the branch already has, fails + * with the branch untouched. The version is checked the way the changelog step will check it: by + * the versioning strategy package.json configures. * * @param {object} core The @actions/core toolkit, for the log and the outputs. * @param {object} exec The @actions/exec toolkit. @@ -539,7 +618,8 @@ async function runOrFail(exec, command, args, options, what) { * @returns {Promise} Resolves once pup has run and the outputs are set. */ async function runPrepare({ core, exec, env, cwd = process.cwd(), fetchImpl = globalThis.fetch }) { - const version = validateVersion(env.INPUT_VERSION) + const changelogger = readChangeloggerStrategy(readCheckoutFile(cwd, 'package.json')) + const version = validateVersion(env.INPUT_VERSION, changelogger.strategy) const date = resolveDate(env.INPUT_DATE) const branch = validateBranch(env.INPUT_BRANCH) const pupVersion = validatePupVersion(env.INPUT_PUP_VERSION) @@ -579,11 +659,16 @@ async function runPrepare({ core, exec, env, cwd = process.cwd(), fetchImpl = gl ) } - if (!hasChangeloggerConfig(readCheckoutFile(cwd, 'package.json'))) { + if (!changelogger.configured) { core.warning( 'package.json has no changelogger section, so the changelog step uses the changelogger ' + 'defaults: a keepachangelog changelog.md with semver versioning.' ) + } else if (changelogger.strategy === 'custom') { + core.warning( + `package.json names a custom versioning strategy (${changelogger.versioning}), so the ` + + "action did not check the version's shape. The changelog step validates it with that strategy." + ) } const pupRelease = await resolvePupRelease({ @@ -651,13 +736,21 @@ async function runPrepareWithPhar({ core, exec, cwd, env, pharRelease, version, 'pup get-version' ) const current = readCurrentVersion(printed) + // Only the numeric start is compared: 4.17.0-beta.1 counts as 4.17.0, which compareVersions + // reads; a pre-release of the version the branch holds is still that version. + const numeric = numericPrefix(version) if (current === null) { core.warning( `The version files hold \`${lastLine(printed)}\`, which is not a numeric version, so ` + `${version} was not checked against it.` ) - } else if (compareVersions(version, current) < 0) { + } else if (numeric === null) { + core.warning( + `${version} holds no numeric version, so it was not checked against the ${current} the ` + + 'branch has.' + ) + } else if (compareVersions(numeric, current) < 0) { throw new Error( `Refusing to prepare ${version}: ${branch} already has ${current}, a later version. Check the ` + 'version and the release branch. Nothing was changed.' @@ -686,7 +779,9 @@ async function runPrepareWithPhar({ core, exec, cwd, env, pharRelease, version, */ async function runCommit({ core, exec, env, cwd = process.cwd() }) { const branch = validateBranch(env.INPUT_BRANCH) - const version = validateVersion(env.INPUT_VERSION) + // The prepare step validated the version against the changelogger strategy; its output is + // what the manifest hands this step, so only an empty value is checked here. + const version = readVersion(env.INPUT_VERSION) const message = commitMessage(branch, version) const token = env.INPUT_TOKEN || '' @@ -759,6 +854,7 @@ async function runCommit({ core, exec, env, cwd = process.cwd() }) { module.exports = { runPrepare, runCommit, + readVersion, validateVersion, resolveDate, validateBranch, @@ -766,7 +862,7 @@ module.exports = { resolvePupRelease, resolvePupDistribution, readPuprc, - hasChangeloggerConfig, + readChangeloggerStrategy, pupDownloadUrl, pupReleaseUrl, pupCommands, @@ -775,5 +871,6 @@ module.exports = { commitMessage, compareVersions, readCurrentVersion, - VERSION_PATTERN, + STELLARWP_VERSION_PATTERN, + SEMVER_PATTERN, } diff --git a/tests/actions/release-prep/release-prep.test.js b/tests/actions/release-prep/release-prep.test.js index cb5fca5..9843f15 100644 --- a/tests/actions/release-prep/release-prep.test.js +++ b/tests/actions/release-prep/release-prep.test.js @@ -14,7 +14,8 @@ const { validateBranch, validatePupVersion, readPuprc, - hasChangeloggerConfig, + readChangeloggerStrategy, + readVersion, pupDownloadUrl, pupReleaseUrl, pupCommands, @@ -196,33 +197,57 @@ function commitEnv(overrides = {}) { describe('release-prep', () => { describe('validateVersion', () => { - it('accepts three or four numeric parts', () => { - assert.equal(validateVersion('4.17.0'), '4.17.0') - assert.equal(validateVersion('4.17.0.1'), '4.17.0.1') + it('accepts three or four numeric parts under the stellarwp strategy', () => { + assert.equal(validateVersion('4.17.0', 'stellarwp'), '4.17.0') + assert.equal(validateVersion('4.17.0.1', 'stellarwp'), '4.17.0.1') }) it('reads the version without surrounding whitespace', () => { - assert.equal(validateVersion(' 4.17.0\n'), '4.17.0') + assert.equal(validateVersion(' 4.17.0\n', 'stellarwp'), '4.17.0') + assert.equal(readVersion(' 4.17.0\n'), '4.17.0') }) it('refuses an empty version, saying the run was dispatched without one', () => { // A release dispatcher that has no version for the release sends an empty one. for (const empty of ['', ' ', undefined]) { - assert.throws(() => validateVersion(empty), /No version was given.*Nothing was changed/s) + for (const strategy of ['stellarwp', 'semver', 'custom']) { + assert.throws(() => validateVersion(empty, strategy), /No version was given.*Nothing was changed/s) + } + assert.throws(() => readVersion(empty), /No version was given.*Nothing was changed/s) } }) - it('refuses a pre-release, which the stellarwp changelog versioning rejects', () => { + it('refuses a pre-release under stellarwp, which that versioning rejects', () => { assert.throws( - () => validateVersion('4.17.0-beta.1'), + () => validateVersion('4.17.0-beta.1', 'stellarwp'), /'4\.17\.0-beta\.1' is not a version this action can prepare.*pre-release/s ) }) - it('refuses anything but three or four numeric parts', () => { + it('refuses anything but three or four numeric parts under stellarwp', () => { // 2.1..21 matters most: the changelogger reads the empty part as 0 and would accept it. for (const version of ['4.17', '4.17.0.1.2', '2.1..21', 'v4.17.0', '4.17.x', '4.17.0.']) { - assert.throws(() => validateVersion(version), /not a version this action can prepare/) + assert.throws(() => validateVersion(version, 'stellarwp'), /not a version this action can prepare/) + } + }) + + it('accepts a semver version, pre-release and build included, under semver', () => { + for (const version of ['4.17.0', '4.17.0-beta.1', '4.17.0-rc.1+build.5', '0.1.0']) { + assert.equal(validateVersion(version, 'semver'), version) + } + }) + + it('refuses a fourth part, a leading v and an empty part under semver', () => { + // The semver package behind the changelogger takes v4.17.0; the version goes into the + // version files and the tag as given, so the action does not. + for (const version of ['4.17.0.1', 'v4.17.0', '2.1..21', '04.17.0', '4.17', '4.17.0-']) { + assert.throws(() => validateVersion(version, 'semver'), /not a version this action can prepare.*semver/s) + } + }) + + it('checks only that the version is not empty under a custom strategy', () => { + for (const version of ['M26.1', '4.17.0.1', '4.17.0-beta.1']) { + assert.equal(validateVersion(version, 'custom'), version) } }) }) @@ -307,12 +332,55 @@ describe('release-prep', () => { }) }) - describe('hasChangeloggerConfig', () => { - it('is true only for a package.json with a changelogger section', () => { - assert.equal(hasChangeloggerConfig(JSON.stringify(PACKAGE_JSON)), true) - assert.equal(hasChangeloggerConfig('{"name":"x"}'), false) - assert.equal(hasChangeloggerConfig(null), false) - assert.equal(hasChangeloggerConfig('not json'), false) + describe('readChangeloggerStrategy', () => { + it('reads the strategy the changelogger section configures', () => { + assert.deepEqual(readChangeloggerStrategy(JSON.stringify(PACKAGE_JSON)), { + strategy: 'stellarwp', + versioning: 'stellarwp', + configured: true, + }) + assert.deepEqual(readChangeloggerStrategy('{"changelogger":{"versioning":"semver"}}'), { + strategy: 'semver', + versioning: 'semver', + configured: true, + }) + }) + + it('falls back to semver without a section, a file or valid JSON, as the changelogger does', () => { + for (const text of ['{"name":"x"}', null, 'not json']) { + assert.deepEqual(readChangeloggerStrategy(text), { + strategy: 'semver', + versioning: 'semver', + configured: false, + }) + } + }) + + it('reads a section without a versioning key as semver, the changelogger default', () => { + assert.deepEqual(readChangeloggerStrategy('{"changelogger":{"changesDir":"changelog"}}'), { + strategy: 'semver', + versioning: 'semver', + configured: true, + }) + }) + + it('reads a .js or .ts file as a custom strategy', () => { + for (const file of ['bin/versioning.js', 'bin/versioning.ts']) { + assert.deepEqual(readChangeloggerStrategy(`{"changelogger":{"versioning":"${file}"}}`), { + strategy: 'custom', + versioning: file, + configured: true, + }) + } + }) + + it('refuses a strategy the changelogger does not know, which would fail the changelog step', () => { + for (const versioning of ['foo', '']) { + assert.throws( + () => readChangeloggerStrategy(`{"changelogger":{"versioning":"${versioning}"}}`), + /is not a versioning strategy the changelogger knows.*Nothing was changed/s + ) + } }) }) @@ -634,6 +702,82 @@ describe('release-prep', () => { assert.match(logged(), /::warning::.*changelogger/) }) + it('validates the version with the strategy package.json configures', async (t) => { + const semver = { changelogger: { versioning: 'semver', changesDir: 'changelog' } } + + const accepted = await coreWithOutputs(t) + const run = recordingExec(allSucceed()) + await runPrepare({ + core: accepted.core, + exec: run.exec, + env: prepareEnv({ INPUT_VERSION: '4.17.0-beta.1' }), + cwd: checkoutWith({ '.puprc': PUPRC, 'package.json': semver }), + }) + assert.ok(sequence(run.calls).some((call) => call.endsWith('replace-version 4.17.0-beta.1'))) + assert.equal(accepted.outputs().version, '4.17.0-beta.1') + + const refused = await coreWithOutputs(t) + const stopped = recordingExec(allSucceed()) + await assert.rejects( + runPrepare({ + core: refused.core, + exec: stopped.exec, + env: prepareEnv({ INPUT_VERSION: '4.17.0.1' }), + cwd: checkoutWith({ '.puprc': PUPRC, 'package.json': semver }), + }), + /'4\.17\.0\.1' is not a version this action can prepare.*semver/s + ) + assert.deepEqual(stopped.calls, [], 'nothing ran for a version the changelog step would refuse') + }) + + it('leaves the shape check to a custom strategy, and says so', async (t) => { + const { core, logged } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const custom = { changelogger: { versioning: 'bin/versioning.js' } } + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': custom }) + + await runPrepare({ core, exec, env: prepareEnv({ INPUT_VERSION: 'M26.1' }), cwd }) + + assert.match(logged(), /::warning::.*custom versioning strategy \(bin\/versioning\.js\)/) + assert.match(logged(), /::warning::.*M26\.1 holds no numeric version/) + assert.ok(sequence(calls).some((call) => call.endsWith('replace-version M26.1'))) + }) + + it('stops before running anything when package.json names a strategy the changelogger does not know', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': { changelogger: { versioning: 'foo' } } }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv(), cwd }), + /changelogger\.versioning to 'foo', which is not a versioning strategy the changelogger knows/ + ) + assert.deepEqual(calls, []) + assert.deepEqual(outputs(), {}) + }) + + it('compares a pre-release by its numeric start against the branch', async (t) => { + const semver = { changelogger: { versioning: 'semver' } } + + for (const [current, accepted] of [ + ['4.18.0', false], + ['4.17.0', true], + ]) { + const { core } = await coreWithOutputs(t) + const { exec } = recordingExec( + allSucceed([{ command: 'php', args: /get-version$/, stdout: `${current}\n` }]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': semver }) + const prepare = runPrepare({ core, exec, env: prepareEnv({ INPUT_VERSION: '4.17.0-beta.1' }), cwd }) + + if (accepted) { + await prepare + } else { + await assert.rejects(prepare, /Refusing to prepare 4\.17\.0-beta\.1: .* already has 4\.18\.0/) + } + } + }) + it('fails with pup output when pup fails, and stops there', async (t) => { const { core, outputs } = await coreWithOutputs(t) const { exec, calls } = recordingExec( From bb8fc3ab4a6c09a6c6d61519259c89217d5629bd Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Tue, 20 Oct 2026 16:09:50 -0300 Subject: [PATCH 26/27] refactor(release-prep): commit through the REST API instead of pushing The commit step committed with git and pushed with the token handed to git through its environment, which took pushEnv, basicCredential, the extraheader reset for a checkout that persisted its credentials, and most of the README's Pushing section. The step now lists the changes with git and creates the commit through the Octokit github-script supplies: one blob per changed file, uploaded as base64 so a binary survives; a tree built on the checkout's own tree, with a deletion as a null sha and the mode read from the raw diff; a commit whose parent is the checkout's own head; and a ref update that is not forced, so a branch that moved after the checkout is refused as a push would refuse it, and the checkout's files are never written over newer ones. The manifest hands the action's token to github-script's own github-token input, which is what the client is built from; without that the token input would never be used. No author or committer is set, so the commit belongs to the token's identity and GitHub signs it. The checkout is left where it was, with the prepared files in the working tree, which the README now says. The runCommit tests run real git to list the changes and assert the requests the API would have received; the local bare remote is gone. Refs: https://github.com/stellarwp/plugin-toolbox/pull/7#discussion_r4325504200 --- .github/actions/release-prep/README.md | 62 ++-- .github/actions/release-prep/action.yml | 26 +- .github/actions/release-prep/release-prep.js | 230 ++++++++------ .../actions/release-prep/release-prep.test.js | 300 +++++++++++------- 4 files changed, 356 insertions(+), 262 deletions(-) diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md index bdee931..2a8a568 100644 --- a/.github/actions/release-prep/README.md +++ b/.github/actions/release-prep/README.md @@ -1,7 +1,7 @@ # release-prep Prepares a release branch: bumps the version, replaces the `@since TBD` placeholders, writes the -changelog, then commits and pushes the result to the branch: +changelog, then commits the result to the branch through the GitHub API: ```yaml - uses: actions/checkout@v6 @@ -31,9 +31,10 @@ Three tools run, in this order, from the root of the checkout: 3. The [changelogger action](https://github.com/stellarwp/changelogger) writes the pending entries in `changelog/` into the changelog files and deletes them, with the version and the date. -Everything the working tree changed is then committed as `Prepare ()` by -`github-actions[bot]` and pushed to `branch`. Run the action on a clean checkout, and put any step -that writes tracked files after it: the commit includes every change in the working tree. +Everything the working tree changed is then committed to `branch` as `Prepare ()`, +through the GitHub API, by the identity of `token`: `github-actions[bot]` for the default token. Run +the action on a clean checkout, and put any step that writes tracked files after it: the commit +includes every change in the working tree. See [Committing](#committing). pup is downloaded from its GitHub release into the runner's temp directory, so it never ends up in the commit. See [Which pup runs](#which-pup-runs). @@ -124,10 +125,10 @@ that is not a valid branch name, and a checkout that is not on `branch`. | Input | Required | Default | What it does | |---|---|---|---| -| `branch` | yes | | The release branch to push to, e.g. `release/M26.jigglypuff`. A branch name, not `refs/heads/…`. The checkout has to be on it; see [Pushing](#pushing) | +| `branch` | yes | | The release branch to commit to, e.g. `release/M26.jigglypuff`. A branch name, not `refs/heads/…`. The checkout has to be on it; see [Committing](#committing) | | `version` | yes | | The version to prepare, e.g. `4.17.0`. See [Versions it refuses](#versions-it-refuses) | | `date` | no | today, in UTC | The changelog date as `YYYY-MM-DD`. Empty or `today` writes today | -| `token` | no | `github.token` | The token the commit is pushed with. See [Pushing](#pushing) | +| `token` | no | `github.token` | The token the commit is created with. See [Committing](#committing) | | `pup-version` | no | `latest` | The pup release to run: a number such as `2.0.0`, or `latest` for the newest. See [Which pup runs](#which-pup-runs) | ## Outputs @@ -136,7 +137,7 @@ that is not a valid branch name, and a checkout that is not on `branch`. |---|---|---| | `version` | `4.17.0` | The version prepared | | `date` | `2026-10-08` | The date written in the changelog | -| `commit` | `3f2a…` | The commit pushed to the branch. Empty when nothing changed | +| `commit` | `3f2a…` | The commit created on the branch. Empty when nothing changed | | `files` | `my-plugin.php` | The files the commit changed, one per line. Empty when nothing changed | | `distribution` | `phar` | Which pup distribution the release carries. `phar` on every run that succeeds, the only distribution the action runs today | @@ -173,7 +174,7 @@ phar path only; the TS contract does not define it yet. ## Running it again Preparing a version the branch already holds changes nothing: the versions match, no TBDs are left -and `changelog/` is empty. The action commits and pushes nothing and succeeds, so a retry of a +and `changelog/` is empty. The action commits nothing, calls no API, and succeeds, so a retry of a finished preparation is safe. ## The date @@ -182,7 +183,7 @@ The changelogger action writes the date exactly as it is given: `today` would la as the word, and an empty value as an empty date. The action resolves both to today's date, in UTC, before the changelog step runs, and that is the date the `date` output reports. -## Pushing +## Committing The job needs `contents: write`: @@ -193,33 +194,39 @@ jobs: contents: write ``` -The action pushes with `token` itself, to `HEAD:refs/heads/`, so the checkout can use -`persist-credentials: false`. The token reaches git through the environment of that one push — -never on its command line, which the log would echo, and never in `.git/config`, where a later -step could read it. The token and the base64 credential built from it are masked in the log. +The commit is created through the GitHub API with `token`, not pushed with git. The action lists +the changes with git (`git add --all`, then `git diff --cached --raw`), uploads each changed file +as a blob, builds a tree on the checkout's own tree, creates a commit whose parent is the +checkout's own head, and points `branch` at it. Nothing is pushed, so the checkout can use +`persist-credentials: false`, and no token reaches git or the log. GitHub signs commits created +this way, so they show as verified. -When the checkout keeps its own credentials, git would send two Authorization headers — its own -and the action's — and GitHub refuses the push with a 400. The action clears the -`http./.extraheader` key before adding its own header, so only `token` is sent. +The ref update is not forced. If the branch moved after the checkout, GitHub refuses the update +as it would refuse a push, the job fails saying so, and the files of the checkout are never +written over newer ones. -The push is a fast-forward. If the branch moved after the checkout, the push is refused and the -job fails with git's output. +The checkout itself is left as it was: its HEAD stays on the commit it had, with the prepared +files in the working tree. A later step that reads the branch with git sees the commit before the +preparation; the `commit` output holds the new one. + +The commit goes to the repository the workflow runs in (`GITHUB_REPOSITORY`). Only regular files +are uploaded; a changed symlink or submodule fails the step, naming the path. The checkout has to be on `branch` itself, and the action checks that before changing anything. -Pushing from another branch would also fast-forward whenever the release branch is an ancestor of -it — for instance a run started from `main` whose checkout was given no `ref` — and the release -would pick up every commit of that branch. A detached checkout is refused for the same reason. +Committing from another branch would carry that branch's commits into the release whenever the +release branch is an ancestor of it — for instance a run started from `main` whose checkout was +given no `ref`. A detached checkout is refused for the same reason. Pass a PAT or app token as `token` when a ruleset on the release branches blocks `github.token`, -or when a workflow in the repository has to run on the push. GitHub creates no workflow run for a -push made with `GITHUB_TOKEN`. +or when a workflow in the repository has to run on the commit. GitHub creates no workflow run for +a commit made with `GITHUB_TOKEN`. ## How it runs Two [actions/github-script](https://github.com/actions/github-script) steps run `release-prep.js`, with the changelogger action between them. The first validates the inputs and the `.puprc`, then -runs pup. The second commits and pushes. github-script supplies the Node runtime and `exec`; there -is no dependency to install and no bundle to build. +runs pup. The second commits through the API. github-script supplies the Node runtime, `exec` and +the `github` client, built from `token`; there is no dependency to install and no bundle to build. The script is loaded by path rather than inlined in the manifest because github-script's `require` resolves against the workspace of the repository being built, not this action's directory. The @@ -230,5 +237,6 @@ or a branch name is data and cannot become part of the program. The commands run arguments as a list, without a shell. `tests/actions/release-prep/release-prep.test.js` covers the rules above. The pup steps run -against a stand-in for `exec`; the commit and push run real git against a local repository. Run -`npm install` once, then `node --test`. +against a stand-in for `exec`; the commit step runs real git against a local checkout to list the +changes, and sends the commit to an Octokit whose network is a stand-in that records every +request. Run `npm install` once, then `node --test`. diff --git a/.github/actions/release-prep/action.yml b/.github/actions/release-prep/action.yml index 963031c..a1cf91d 100644 --- a/.github/actions/release-prep/action.yml +++ b/.github/actions/release-prep/action.yml @@ -1,17 +1,17 @@ # Prepares a release branch: bumps the version, replaces the TBD placeholders, writes the changelog, -# then commits that and pushes it to the branch. It opens no pull request, so the change is reviewed -# wherever the release itself is reviewed. +# then commits that to the branch through the GitHub API. It opens no pull request, so the change +# is reviewed wherever the release itself is reviewed. # -# The checks and the git work live in release-prep.js, run by actions/github-script, so there is no +# The checks and the commit live in release-prep.js, run by actions/github-script, so there is no # dependency to install and nothing to build. The changelog is written by the changelogger's own # action between the two script steps, rather than reimplemented here. name: Release prep -description: Bumps the version, replaces TBDs and writes the changelog, then pushes it to the release branch +description: Bumps the version, replaces TBDs and writes the changelog, then commits it to the release branch inputs: branch: description: >- - The release branch to push the preparation to, e.g. `release/M26.jigglypuff`. A branch name, not + The release branch to commit the preparation to, e.g. `release/M26.jigglypuff`. A branch name, not any git ref. The action does not check it out: the caller's checkout has to be on it, and the action refuses to run when it is not. required: true @@ -30,9 +30,9 @@ inputs: default: '' token: description: >- - Token the commit is pushed with. It needs `contents: write` on the repository, so the job needs - that permission too. The action pushes with it explicitly, so the checkout can use - `persist-credentials: false`. + Token the commit is created with, through the GitHub API. It needs `contents: write` on the + repository, so the job needs that permission too. Nothing is pushed with git, so the checkout + can use `persist-credentials: false`. required: false default: ${{ github.token }} pup-version: @@ -52,7 +52,7 @@ outputs: description: 'The date written in the changelog, as `YYYY-MM-DD`.' value: ${{ steps.prepare.outputs.date }} commit: - description: 'The commit pushed to the branch. Empty when nothing changed and nothing was pushed.' + description: 'The commit created on the branch. Empty when nothing changed and nothing was committed.' value: ${{ steps.commit.outputs.commit }} files: description: 'The files the commit changed, one per line. Empty when nothing changed.' @@ -97,7 +97,7 @@ runs: version: ${{ steps.prepare.outputs.version }} date: ${{ steps.prepare.outputs.date }} - - name: Commit the preparation and push it to the release branch + - name: Commit the preparation to the release branch id: commit uses: actions/github-script@v9 env: @@ -105,8 +105,10 @@ runs: # The values the prepare step validated, not the raw inputs. INPUT_BRANCH: ${{ steps.prepare.outputs.branch }} INPUT_VERSION: ${{ steps.prepare.outputs.version }} - INPUT_TOKEN: ${{ inputs.token }} with: + # github-script builds its `github` client from its own token input, which defaults to + # github.token: the action's token has to be handed over here or it is never used. + github-token: ${{ inputs.token }} script: | const { runCommit } = require(`${process.env.ACTION_PATH}/release-prep.js`) - await runCommit({ core, exec, env: process.env }) + await runCommit({ github, core, exec, env: process.env }) diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index 75ae21d..f59c841 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -1,13 +1,14 @@ 'use strict' /** - * Prepares a release branch: bumps the version, replaces the TBD placeholders, and commits and - * pushes what the changelog step wrote alongside them. + * Prepares a release branch: bumps the version, replaces the TBD placeholders, and commits what the + * changelog step wrote alongside them to the branch, through the GitHub API. * * The action runs in two github-script steps with the changelogger action between them, so there - * are two entry points. runPrepare validates everything and runs pup; runCommit commits and pushes. - * The functions they are built from are pure, apart from the reads of .puprc and package.json and - * the commands run through `exec`, so a test can pass a stand-in and assert the calls. + * are two entry points. runPrepare validates everything and runs pup; runCommit lists the changes + * with git and creates the commit with the Octokit github-script supplies. The functions they are + * built from are pure, apart from the reads of the checkout, the commands run through `exec` and + * the API calls, so a test can pass stand-ins and assert the calls and the requests. */ const fs = require('node:fs') @@ -41,11 +42,8 @@ const PUP_VERSION_PATTERN = /^\d+\.\d+\.\d+$/ /** A calendar date as the changelog writing strategies expect it. */ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ -/** The identity of the bot GitHub shows for commits made with the default token. */ -const COMMIT_AUTHOR = { - name: 'github-actions[bot]', - email: '41898282+github-actions[bot]@users.noreply.github.com', -} +/** The git modes of a regular file, the only kind of change the action uploads. */ +const BLOB_MODES = new Set(['100644', '100755']) /** * Reads the version input, refusing an empty one. @@ -142,7 +140,7 @@ function resolveDate(date, now = new Date()) { } /** - * Checks the shape of the branch to push to. Whether git accepts the name is checked by git, in + * Checks the shape of the branch to commit to. Whether git accepts the name is checked by git, in * runPrepare. * * @param {string} branch The `branch` input. @@ -158,7 +156,7 @@ function validateBranch(branch) { throw new Error('No branch was given: pass the release branch to prepare, e.g. release/M26.jigglypuff.') } - // The push writes refs/heads/, so a full ref would create a branch named refs/heads/…. + // The commit updates refs/heads/, so a full ref would name a branch refs/heads/…. if (trimmed.startsWith('refs/')) { throw new Error( `Refusing to prepare '${trimmed}': pass the branch name, e.g. ` + @@ -244,7 +242,7 @@ async function resolvePupRelease({ version, token, fetchImpl }) { * Reads what the action needs from the repository's .puprc. * * pup itself does not fail on a missing or broken .puprc: it reads either as empty, bumps nothing - * and exits 0. So the file is checked here, before pup runs, and a release branch is never pushed + * and exits 0. So the file is checked here, before pup runs, and a release branch never gets a commit * with the version left as it was. * * @param {string|null} text The contents of .puprc, or null when there is none. @@ -452,52 +450,37 @@ function pupCommands(phar, version) { } /** - * The basic credential git sends for a token, as actions/checkout builds it. - * - * @param {string} token The token. - * - * @returns {string} base64 of `x-access-token:`. - */ -function basicCredential(token) { - return Buffer.from(`x-access-token:${token}`).toString('base64') -} - -/** - * The environment that hands git the token for one push. - * - * git reads GIT_CONFIG_COUNT and the numbered KEY/VALUE pairs as extra config for that one - * process. The token therefore never sits on a command line, which the log echoes, and is never - * written to .git/config, where a later step could read it. That is what lets a caller check out - * with `persist-credentials: false`. + * Reads the changes `git diff --cached --raw --no-renames -z` lists. * - * A checkout that does persist its credentials puts its own header under the same key, and git - * sends every value of it: two Authorization headers, which GitHub refuses with a 400. An empty - * value resets the key's list, so the first entry clears whatever the checkout left and only this - * token is sent. + * Each entry is `: `, NUL, the path, NUL. The + * raw format carries the modes, which the tree entries need and `--name-status` drops; + * `--no-renames` keeps a rename as its deletion and addition, since the tree takes one entry per + * path; `-z` keeps a path with a space or a quote intact. * - * @param {string} token The token, or empty to leave authentication to the checkout. - * @param {string} serverUrl The GitHub server, e.g. https://github.com. + * @param {string} output What git printed. * - * @returns {object} The variables to add to git's environment; none without a token. + * @returns {{path: string, status: string, mode: string, previousMode: string}[]} The changes, in + * the order git lists them. */ -function pushEnv(token, serverUrl) { - if (!token) { - return {} - } - - const key = `http.${serverUrl.replace(/\/+$/, '')}/.extraheader` +function parseRawDiff(output) { + const fields = String(output ?? '').split('\0') + const changes = [] + + for (let i = 0; i + 1 < fields.length; i += 2) { + const header = fields[i] + if (!header.startsWith(':')) { + continue + } - return { - GIT_CONFIG_COUNT: '2', - GIT_CONFIG_KEY_0: key, - GIT_CONFIG_VALUE_0: '', - GIT_CONFIG_KEY_1: key, - GIT_CONFIG_VALUE_1: `AUTHORIZATION: basic ${basicCredential(token)}`, + const [previousMode, mode, , , status] = header.slice(1).split(' ') + changes.push({ path: fields[i + 1], status, mode, previousMode }) } + + return changes } /** - * The message of the commit the action pushes. + * The message of the commit the action creates. * * @param {string} branch The release branch. * @param {string} version The version prepared. @@ -633,9 +616,9 @@ async function runPrepare({ core, exec, env, cwd = process.cwd(), fetchImpl = gl throw new Error(`'${branch}' is not a valid branch name. Nothing was changed.`) } - // The push writes HEAD to refs/heads/. From any other branch that the release branch is an - // ancestor of, git would accept it as a fast-forward and carry that branch's commits into the - // release, so the checkout has to be on the release branch itself. + // The commit's parent is the checkout's HEAD and it lands on refs/heads/. From any other + // branch that the release branch is an ancestor of, GitHub would accept it as a fast-forward and + // carry that branch's commits into the release, so the checkout has to be on the release branch. const head = await exec.getExecOutput('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd, ignoreReturnCode: true, @@ -763,46 +746,58 @@ async function runPrepareWithPhar({ core, exec, cwd, env, pharRelease, version, } /** - * Commits whatever the preparation changed and pushes it to the release branch. + * Commits whatever the preparation changed to the release branch, through the GitHub API. + * + * The changes are listed with git, then sent as blobs and a tree built on the checkout's own tree, + * a commit whose parent is the checkout's own head, and a ref update that is not forced: when the + * branch moved after the checkout, GitHub refuses the update as it would refuse a push, and the + * checkout's files are never written over newer ones. The checkout itself is left as it was: its + * HEAD stays on the commit it had, with the changes in the working tree. * * When nothing changed, which is what re-preparing a version that is already on the branch finds, - * it neither commits nor pushes, and succeeds. + * it calls nothing and succeeds. * - * @param {object} core The @actions/core toolkit, for the log, the outputs and the summary. - * @param {object} exec The @actions/exec toolkit. - * @param {object} env The environment the action manifest put its inputs in. - * @param {string} cwd The checkout, the working directory by default. + * @param {object} github An Octokit, as actions/github-script supplies it, authenticated with the + * action's token. + * @param {object} core The @actions/core toolkit, for the log, the outputs and the summary. + * @param {object} exec The @actions/exec toolkit. + * @param {object} env The environment the action manifest put its inputs in. + * @param {string} cwd The checkout, the working directory by default. * - * @throws {Error} When a git command fails, the push included. + * @throws {Error} When a git command or an API call fails, a change is not a regular file, or + * the branch moved after the checkout. * - * @returns {Promise} Resolves once the commit is pushed, or found unnecessary. + * @returns {Promise} Resolves once the commit is on the branch, or found unnecessary. */ -async function runCommit({ core, exec, env, cwd = process.cwd() }) { +async function runCommit({ github, core, exec, env, cwd = process.cwd() }) { const branch = validateBranch(env.INPUT_BRANCH) // The prepare step validated the version against the changelogger strategy; its output is // what the manifest hands this step, so only an empty value is checked here. const version = readVersion(env.INPUT_VERSION) const message = commitMessage(branch, version) - const token = env.INPUT_TOKEN || '' + const [owner, repo] = String(env.GITHUB_REPOSITORY || '').split('/') - if (token) { - core.setSecret(token) - core.setSecret(basicCredential(token)) + 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.' + ) } await runOrFail(exec, 'git', ['add', '--all'], { cwd }, 'Staging the changes') - const staged = await runOrFail( + const raw = await runOrFail( exec, 'git', - ['diff', '--cached', '--name-only'], + ['diff', '--cached', '--raw', '--no-renames', '-z'], { cwd, silent: true }, 'Listing the changes' ) - const files = staged.split('\n').filter(Boolean) + const changes = parseRawDiff(raw) + const files = changes.map((change) => change.path) - if (files.length === 0) { - core.info(`Nothing changed: ${branch} already holds ${version}. Nothing was committed or pushed.`) + if (changes.length === 0) { + core.info(`Nothing changed: ${branch} already holds ${version}. Nothing was committed.`) core.setOutput('commit', '') core.setOutput('files', '') await core.summary @@ -812,41 +807,75 @@ async function runCommit({ core, exec, env, cwd = process.cwd() }) { return } - await runOrFail( - exec, - 'git', - [ - '-c', - `user.name=${COMMIT_AUTHOR.name}`, - '-c', - `user.email=${COMMIT_AUTHOR.email}`, - 'commit', - '--quiet', - '-m', - message, - ], - { cwd }, - 'Committing the changes' + for (const change of changes) { + if (change.status !== 'D' && !BLOB_MODES.has(change.mode)) { + throw new Error( + `${change.path} has mode ${change.mode}, which this action does not commit: only regular ` + + 'files are uploaded. Nothing was committed.' + ) + } + } + + const [head, baseTree] = ( + await runOrFail( + exec, + 'git', + ['rev-parse', 'HEAD', 'HEAD^{tree}'], + { cwd, silent: true }, + 'Reading the checkout head' + ) ) + .trim() + .split('\n') + + const tree = [] + for (const change of changes) { + if (change.status === 'D') { + tree.push({ path: change.path, mode: change.previousMode, type: 'blob', sha: null }) + continue + } - const commit = ( - await runOrFail(exec, 'git', ['rev-parse', 'HEAD'], { cwd, silent: true }, 'Reading the commit') - ).trim() + // Every file goes up as base64, so a binary arrives byte for byte. + const { data: blob } = await github.rest.git.createBlob({ + owner, + repo, + content: fs.readFileSync(path.join(cwd, change.path)).toString('base64'), + encoding: 'base64', + }) + tree.push({ path: change.path, mode: change.mode, type: 'blob', sha: blob.sha }) + } + + const { data: newTree } = await github.rest.git.createTree({ owner, repo, base_tree: baseTree, tree }) + // No author or committer: the commit belongs to the token's identity, github-actions[bot] for the + // default token, and GitHub signs it. + const { data: commit } = await github.rest.git.createCommit({ + owner, + repo, + message, + tree: newTree.sha, + parents: [head], + }) - await runOrFail( - exec, - 'git', - ['push', 'origin', `HEAD:refs/heads/${branch}`], - { cwd, env: { ...env, ...pushEnv(token, env.GITHUB_SERVER_URL || 'https://github.com') } }, - `Pushing to ${branch}` - ) + try { + await github.rest.git.updateRef({ owner, repo, ref: `heads/${branch}`, sha: commit.sha }) + } catch (error) { + if (error.status === 422) { + throw new Error( + `${branch} moved after the checkout, so the commit would not be a fast-forward ` + + `(${error.message}). Run the step again on a fresh checkout. Nothing was committed to ` + + 'the branch.' + ) + } + + throw error + } - core.info(`Pushed ${commit} to ${branch}.`) - core.setOutput('commit', commit) + core.info(`Committed ${commit.sha} to ${branch}.`) + core.setOutput('commit', commit.sha) core.setOutput('files', files.join('\n')) // A Markdown list rather than addList, whose HTML list would show the backticks literally. await core.summary - .addRaw(`Pushed \`${commit.slice(0, 7)}\` "${message}" to \`${branch}\`:\n\n`) + .addRaw(`Committed \`${commit.sha.slice(0, 7)}\` "${message}" to \`${branch}\`:\n\n`) .addRaw(files.map((file) => `- \`${file}\``).join('\n'), true) .write() } @@ -866,8 +895,7 @@ module.exports = { pupDownloadUrl, pupReleaseUrl, pupCommands, - basicCredential, - pushEnv, + parseRawDiff, commitMessage, compareVersions, readCurrentVersion, diff --git a/tests/actions/release-prep/release-prep.test.js b/tests/actions/release-prep/release-prep.test.js index 9843f15..b451917 100644 --- a/tests/actions/release-prep/release-prep.test.js +++ b/tests/actions/release-prep/release-prep.test.js @@ -19,13 +19,12 @@ const { pupDownloadUrl, pupReleaseUrl, pupCommands, - basicCredential, - pushEnv, + parseRawDiff, commitMessage, compareVersions, readCurrentVersion, } = require('../../../.github/actions/release-prep/release-prep.js') -const { coreWithOutputs, recordingExec } = require('../../support/actions.js') +const { coreWithOutputs, recordingExec, octokitFor } = require('../../support/actions.js') /** A .puprc that bumps the plugin header and replaces TBDs under src/. */ const PUPRC = { @@ -118,32 +117,76 @@ function git(dir, ...args) { return execFileSync('git', args, { cwd: dir, encoding: 'utf8' }).trim() } +/** The bytes of a small binary file, which a text upload would corrupt. */ +const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff]) + /** - * A working copy of a repository whose `origin` is a local bare repository holding `branch`. + * A checkout of a release branch: one commit holding a version file, an executable, a binary and a + * pending changelog entry. * - * The commit step then runs real git against it: a push goes to the bare repository, so a test can - * read back what landed on the branch. + * The commit step runs real git against it to list the changes, and sends the commit itself to an + * Octokit whose network is a stand-in, so a test asserts the requests the API would have received. * - * @param {string} branch The release branch the working copy has checked out. + * @param {string} branch The release branch the checkout is on. * - * @returns {{work: string, remote: string}} The two directories. + * @returns {string} The checkout. */ -function repositoryOn(branch) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'release-prep-git-')) - const remote = path.join(root, 'remote.git') - const work = path.join(root, 'work') +function checkoutOn(branch) { + const work = fs.mkdtempSync(path.join(os.tmpdir(), 'release-prep-git-')) - git(root, 'init', '--quiet', '--bare', remote) - git(root, 'init', '--quiet', '-b', branch, work) + git(work, 'init', '--quiet', '-b', branch) git(work, 'config', 'user.name', 'Test') git(work, 'config', 'user.email', 'test@example.com') + fs.mkdirSync(path.join(work, 'bin')) + fs.mkdirSync(path.join(work, 'changelog')) fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.16.0\n') + fs.writeFileSync(path.join(work, 'bin', 'tool.sh'), '#!/bin/sh\n', { mode: 0o755 }) + fs.writeFileSync(path.join(work, 'logo.png'), PNG_BYTES) + fs.writeFileSync(path.join(work, 'changelog', 'entry.yaml'), 'type: fix\n') git(work, 'add', '-A') git(work, 'commit', '--quiet', '-m', 'Initial') - git(work, 'remote', 'add', 'origin', remote) - git(work, 'push', '--quiet', 'origin', `HEAD:refs/heads/${branch}`) - return { work, remote } + return work +} + +/** The repository paths the commit step writes, as a real Octokit spells them. */ +const API = { + blobs: '/repos/stellarwp/plugin/git/blobs', + trees: '/repos/stellarwp/plugin/git/trees', + commits: '/repos/stellarwp/plugin/git/commits', + ref: '/repos/stellarwp/plugin/git/refs/heads%2Frelease%2FM26.jigglypuff', +} + +/** + * Rules under which every API write succeeds, each blob getting a sha of its own. + * + * @param {object[]} before Rules that take precedence, to make one write fail. + * + * @returns {object[]} Rules for recordingFetch. + */ +function apiAccepts(before = []) { + return [ + ...before, + { + method: 'POST', + path: API.blobs, + responses: [1, 2, 3, 4, 5].map((n) => ({ body: { sha: `blob-${n}` } })), + }, + { method: 'POST', path: API.trees, body: { sha: 'tree-new' } }, + { method: 'POST', path: API.commits, body: { sha: 'commit-new' } }, + { method: 'PATCH', path: API.ref, body: { object: { sha: 'commit-new' } } }, + ] +} + +/** + * Each request as `METHOD path`, for asserting the order the commit step worked in. + * + * @param {object[]} requests The requests recordingFetch recorded. + * + * @returns {string[]} One line per request. + */ +function requested(requests) { + return requests.map((request) => `${request.method} ${request.path}`) } /** @@ -189,8 +232,7 @@ function commitEnv(overrides = {}) { ...process.env, INPUT_BRANCH: 'release/M26.jigglypuff', INPUT_VERSION: '4.17.0', - INPUT_TOKEN: 'ghs_notarealtoken123', - GITHUB_SERVER_URL: 'https://github.com', + GITHUB_REPOSITORY: 'stellarwp/plugin', ...overrides, } } @@ -404,26 +446,24 @@ describe('release-prep', () => { }) }) - describe('the push credential', () => { - it('is the basic credential git uses for a token', () => { - const credential = basicCredential('ghs_abc') - - assert.equal(Buffer.from(credential, 'base64').toString(), 'x-access-token:ghs_abc') - }) - - it('reaches git through its environment, scoped to the server, after clearing the key', () => { - // The empty first value resets the list, dropping a header the checkout persisted. - assert.deepEqual(pushEnv('ghs_abc', 'https://github.com'), { - GIT_CONFIG_COUNT: '2', - GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader', - GIT_CONFIG_VALUE_0: '', - GIT_CONFIG_KEY_1: 'http.https://github.com/.extraheader', - GIT_CONFIG_VALUE_1: `AUTHORIZATION: basic ${basicCredential('ghs_abc')}`, - }) + describe('parseRawDiff', () => { + it('reads each change with its modes, status and path from the NUL-separated raw diff', () => { + const raw = + ':100644 100644 1111111 2222222 M\0plugin.php\0' + + ':000000 100644 0000000 3333333 A\0changelog.txt\0' + + ':100644 000000 4444444 0000000 D\0changelog/entry.yaml\0' + + ':100644 100755 5555555 5555555 M\0bin/tool.sh\0' + + assert.deepEqual(parseRawDiff(raw), [ + { path: 'plugin.php', status: 'M', mode: '100644', previousMode: '100644' }, + { path: 'changelog.txt', status: 'A', mode: '100644', previousMode: '000000' }, + { path: 'changelog/entry.yaml', status: 'D', mode: '000000', previousMode: '100644' }, + { path: 'bin/tool.sh', status: 'M', mode: '100755', previousMode: '100644' }, + ]) }) - it('adds nothing without a token, leaving the checkout credentials to git', () => { - assert.deepEqual(pushEnv('', 'https://github.com'), {}) + it('reads an empty diff as no changes', () => { + assert.deepEqual(parseRawDiff(''), []) }) }) @@ -952,118 +992,134 @@ describe('release-prep', () => { }) describe('runCommit', () => { - it('commits every change and pushes it to the release branch', async (t) => { + it("commits every change to the branch through the API, on the checkout's own head", async (t) => { const { core, outputs, summary } = await coreWithOutputs(t) const { exec } = await recordedRealExec() - const { work, remote } = repositoryOn('release/M26.jigglypuff') + const { github, requests } = await octokitFor(apiAccepts()) + const work = checkoutOn('release/M26.jigglypuff') + const head = git(work, 'rev-parse', 'HEAD') + const baseTree = git(work, 'rev-parse', 'HEAD^{tree}') fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.17.0\n') fs.writeFileSync(path.join(work, 'changelog.txt'), '= 4.17.0 =\n') + fs.unlinkSync(path.join(work, 'changelog', 'entry.yaml')) + fs.writeFileSync(path.join(work, 'bin', 'tool.sh'), '#!/bin/sh\necho\n', { mode: 0o755 }) + fs.writeFileSync(path.join(work, 'logo.png'), Buffer.concat([PNG_BYTES, Buffer.from([0x01])])) + + await runCommit({ github, core, exec, env: commitEnv(), cwd: work }) + + assert.deepEqual(requested(requests), [ + `POST ${API.blobs}`, + `POST ${API.blobs}`, + `POST ${API.blobs}`, + `POST ${API.blobs}`, + `POST ${API.trees}`, + `POST ${API.commits}`, + `PATCH ${API.ref}`, + ]) - await runCommit({ core, exec, env: commitEnv(), cwd: work }) - - const pushed = git(remote, 'rev-parse', 'refs/heads/release/M26.jigglypuff') - assert.equal(pushed, git(work, 'rev-parse', 'HEAD')) - assert.equal(git(remote, 'log', '-1', '--format=%s', pushed), 'Prepare release/M26.jigglypuff (4.17.0)') - assert.equal( - git(remote, 'log', '-1', '--format=%an <%ae>', pushed), - 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>' + const blobs = requests.filter((request) => request.path === API.blobs).map((request) => request.body) + assert.ok( + blobs.every((blob) => blob.encoding === 'base64'), + 'every file is uploaded as base64, so a binary survives' ) - assert.deepEqual(outputs(), { commit: pushed, files: 'changelog.txt\nplugin.php' }) - assert.match(summary(), /Prepare release\/M26\.jigglypuff \(4\.17\.0\)/) - assert.match(summary(), /^- `changelog\.txt`\n- `plugin\.php`$/m) - }) + const logo = Buffer.concat([PNG_BYTES, Buffer.from([0x01])]).toString('base64') + assert.ok(blobs.some((blob) => blob.content === logo), 'the binary is uploaded byte for byte') + + const tree = requests.find((request) => request.path === API.trees).body + assert.equal(tree.base_tree, baseTree, "the tree builds on the checkout's own tree") + assert.deepEqual(tree.tree, [ + { path: 'bin/tool.sh', mode: '100755', type: 'blob', sha: 'blob-1' }, + { path: 'changelog.txt', mode: '100644', type: 'blob', sha: 'blob-2' }, + { path: 'changelog/entry.yaml', mode: '100644', type: 'blob', sha: null }, + { path: 'logo.png', mode: '100644', type: 'blob', sha: 'blob-3' }, + { path: 'plugin.php', mode: '100644', type: 'blob', sha: 'blob-4' }, + ]) - it('never puts the token on a command line, in the log or in the git config', async (t) => { - const { core, logged } = await coreWithOutputs(t) - const { exec, calls, printed } = await recordedRealExec() - const { work } = repositoryOn('release/M26.jigglypuff') - fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.17.0\n') - const token = commitEnv().INPUT_TOKEN - const credential = basicCredential(token) - - await runCommit({ core, exec, env: commitEnv(), cwd: work }) - - assert.match(printed(), /\[command\].*git push origin HEAD:refs\/heads\/release\/M26\.jigglypuff/) - const log = `${logged()}\n${printed()}` - assert.match(log, new RegExp(`::add-mask::${token}`)) - assert.match(log, new RegExp(`::add-mask::${credential}`)) - - const unmasked = log.split('\n').filter((line) => !line.startsWith('::add-mask::')) - assert.ok(!unmasked.some((line) => line.includes(token) || line.includes(credential))) - assert.ok(!calls.some((call) => call.args.join(' ').includes(credential))) - assert.ok(!fs.readFileSync(path.join(work, '.git', 'config'), 'utf8').includes(credential)) - - const push = calls.find((call) => call.args[0] === 'push') - assert.deepEqual(push.args, ['push', 'origin', 'HEAD:refs/heads/release/M26.jigglypuff']) - assert.equal(push.options.env.GIT_CONFIG_VALUE_1, `AUTHORIZATION: basic ${credential}`) - }) - - it('sends only its own credential when the checkout persisted another', async (t) => { - // actions/checkout persists its token under the same http./.extraheader key, and git - // sends every value of that key. Two Authorization headers make GitHub answer 400. - const http = require('node:http') - const seen = [] - const server = http.createServer((request, response) => { - const { rawHeaders } = request - seen.push( - rawHeaders.filter((value, index) => index % 2 === 1 && /^authorization$/i.test(rawHeaders[index - 1])) - ) - response.writeHead(404).end() + const commit = requests.find((request) => request.path === API.commits).body + assert.deepEqual(commit, { + message: 'Prepare release/M26.jigglypuff (4.17.0)', + tree: 'tree-new', + parents: [head], }) - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) - t.after(() => server.close()) - - const serverUrl = `http://127.0.0.1:${server.address().port}` - const { core } = await coreWithOutputs(t) - const { exec } = await recordedRealExec() - const { work } = repositoryOn('release/M26.jigglypuff') - git(work, 'remote', 'set-url', 'origin', `${serverUrl}/stellarwp/plugin.git`) - git(work, 'config', `http.${serverUrl}/.extraheader`, 'AUTHORIZATION: basic cGVyc2lzdGVk') - fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.17.0\n') - const env = commitEnv({ GITHUB_SERVER_URL: serverUrl, GIT_TERMINAL_PROMPT: '0' }) - // The server has no repository, so the push fails once git has sent its first request. - await assert.rejects(runCommit({ core, exec, env, cwd: work }), /Pushing to release\/M26\.jigglypuff failed/) + const ref = requests.find((request) => request.path === API.ref).body + assert.deepEqual(ref, { sha: 'commit-new' }, 'the ref update is not forced') - assert.ok(seen.length > 0, 'git reached the server') - for (const headers of seen) { - assert.deepEqual(headers, [`basic ${basicCredential(env.INPUT_TOKEN)}`]) - } + assert.deepEqual(outputs(), { + commit: 'commit-new', + files: 'bin/tool.sh\nchangelog.txt\nchangelog/entry.yaml\nlogo.png\nplugin.php', + }) + assert.match(summary(), /Committed `commit-` "Prepare release\/M26\.jigglypuff \(4\.17\.0\)" to `release\/M26\.jigglypuff`/) + assert.match(summary(), /^- `bin\/tool\.sh`\n- `changelog\.txt`\n- `changelog\/entry\.yaml`\n- `logo\.png`\n- `plugin\.php`$/m) + assert.equal(git(work, 'rev-parse', 'HEAD'), head, 'the checkout itself stays where it was') }) - it('neither commits nor pushes when nothing changed, and still succeeds', async (t) => { + it('neither commits nor calls the API when nothing changed, and still succeeds', async (t) => { // A retry of the same version finds the branch already prepared. const { core, outputs } = await coreWithOutputs(t) - const { exec, calls } = await recordedRealExec() - const { work, remote } = repositoryOn('release/M26.jigglypuff') - const before = git(remote, 'rev-parse', 'refs/heads/release/M26.jigglypuff') + const { exec } = await recordedRealExec() + const { github, requests } = await octokitFor(apiAccepts()) + const work = checkoutOn('release/M26.jigglypuff') - await runCommit({ core, exec, env: commitEnv(), cwd: work }) + await runCommit({ github, core, exec, env: commitEnv(), cwd: work }) - assert.equal(git(remote, 'rev-parse', 'refs/heads/release/M26.jigglypuff'), before) - assert.ok(!calls.some((call) => call.args.includes('commit') || call.args.includes('push'))) + assert.deepEqual(requests, []) assert.deepEqual(outputs(), { commit: '', files: '' }) }) - it('fails with git output when the push is refused', async (t) => { + it('fails when the branch moved after the checkout, without forcing the ref', async (t) => { + // GitHub refuses a ref update that is not a fast-forward with 422, as a push would. const { core } = await coreWithOutputs(t) const { exec } = await recordedRealExec() - const { work, remote } = repositoryOn('release/M26.jigglypuff') + const { github, requests } = await octokitFor( + apiAccepts([{ method: 'PATCH', path: API.ref, status: 422, body: { message: 'Update is not a fast forward' } }]) + ) + const work = checkoutOn('release/M26.jigglypuff') + fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.17.0\n') - // Someone else pushed to the branch after the checkout, so the push is not a fast-forward. - const other = path.join(path.dirname(remote), 'other') - git(path.dirname(remote), 'clone', '--quiet', '-b', 'release/M26.jigglypuff', remote, other) - git(other, 'config', 'user.name', 'Other') - git(other, 'config', 'user.email', 'other@example.com') - git(other, 'commit', '--quiet', '--allow-empty', '-m', 'Elsewhere') - git(other, 'push', '--quiet', 'origin', 'HEAD:refs/heads/release/M26.jigglypuff') + await assert.rejects( + runCommit({ github, core, exec, env: commitEnv(), cwd: work }), + /release\/M26\.jigglypuff moved after the checkout.*Update is not a fast forward.*Nothing was committed/s + ) + assert.ok(!requests.some((request) => request.body?.force), 'no ref update is forced') + }) + it('fails with the API error when a write fails', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + const { github } = await octokitFor( + apiAccepts([{ method: 'POST', path: API.trees, status: 500, body: { message: 'boom' } }]) + ) + const work = checkoutOn('release/M26.jigglypuff') fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.17.0\n') + await assert.rejects(runCommit({ github, core, exec, env: commitEnv(), cwd: work }), /boom/) + }) + + it('refuses a change that is not a regular file, naming it', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + const { github, requests } = await octokitFor(apiAccepts()) + const work = checkoutOn('release/M26.jigglypuff') + fs.symlinkSync('plugin.php', path.join(work, 'link.php')) + await assert.rejects( - runCommit({ core, exec, env: commitEnv(), cwd: work }), - /Pushing to release\/M26\.jigglypuff failed.*rejected/s + runCommit({ github, core, exec, env: commitEnv(), cwd: work }), + /link\.php has mode 120000, which this action does not commit/ ) + assert.deepEqual(requests, [], 'nothing is uploaded') + }) + + it('fails before listing anything when the repository is unknown', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec([]) + const { github } = await octokitFor(apiAccepts()) + + await assert.rejects( + runCommit({ github, core, exec, env: commitEnv({ GITHUB_REPOSITORY: '' }), cwd: '/nowhere' }), + /GITHUB_REPOSITORY/ + ) + assert.deepEqual(calls, []) }) }) }) From 2e11f84d96b6b1004252db814c94096c39537a4f Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Tue, 20 Oct 2026 18:16:17 -0300 Subject: [PATCH 27/27] docs(release-prep): leave how the TypeScript line is called open, and finish the push wording The README prescribed wiring the TypeScript line of pup through its own GitHub Action, with a `uses: stellarwp/pup@v3` example, while the review thread leans towards calling it straight from the script once it ships. The paragraph now states both options and that either is local to the script; the sentence about the floating tag went with the example. Text that still described a push after the move to the API is updated: the template's comments and input description, the root README, the JSDoc of resolvePupRelease (a missing release is caught by the release-page check now, not by the download), the phar-less branch comment (the branch-version check lives in runPrepareWithPhar, not below), and the ref-update error, which blamed a moved branch although GitHub answers 422 for a deleted one too. The warning list of the README gains the custom-strategy case the code already emits. Refs: https://github.com/stellarwp/plugin-toolbox/pull/7#discussion_r4325239239 Refs: https://github.com/stellarwp/plugin-toolbox/pull/7#discussion_r4325280021 --- .github/actions/release-prep/README.md | 32 ++++++++--------- .github/actions/release-prep/release-prep.js | 34 ++++++++++--------- README.md | 2 +- templates/workflows/release-prep.yml | 14 ++++---- .../actions/release-prep/release-prep.test.js | 6 ++-- 5 files changed, 43 insertions(+), 45 deletions(-) diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md index 2a8a568..f10db3c 100644 --- a/.github/actions/release-prep/README.md +++ b/.github/actions/release-prep/README.md @@ -76,11 +76,13 @@ The action checks the `.puprc` before running anything, because pup does not: a that is not JSON, or one with no `paths.versions` makes pup change nothing and exit 0. The job fails on any of the three, with the branch untouched. -Two gaps only produce a warning; the run continues: +Three cases only produce a warning; the run continues: - No `checks.tbd.dirs`: pup falls back to its default and replaces TBDs under `src/` only. - No `changelogger` section: the changelogger uses its defaults — a keepachangelog `changelog.md` with semver versioning — and the action validates the version as semver. +- A custom versioning strategy, `versioning` set to a `.js` or `.ts` file: the action does not + check the version's shape. See [Versions it refuses](#versions-it-refuses). A writing strategy given as a `.js` file is loaded from the checkout; it has to be there before the action runs. One written in TypeScript has to be built in an earlier step. @@ -154,22 +156,16 @@ publishes one; the phar downloads into the runner's temp directory and runs on t A release without one is not supported yet: the prepare step fails, saying so and naming a pin to use instead. A pinned release that does not exist, such as a mistyped number, fails the step with "is not a release"; the action tells the two apart with a second HEAD request, to the -release's page. The TypeScript line of pup publishes no phar and is meant to run through its own -GitHub Action. The manifest cannot carry that call behind a condition: a composite action's -`uses:` references resolve when the job is set up, not when the step runs, so a conditional -reference to an action that does not exist yet would break every run. When the TypeScript line -publishes its action, the call is wired in as the changelogger's is — one `uses:` step per pup -command, `command:` as the input: +release's page. -```yaml -- uses: stellarwp/pup@v3 - with: - command: replace-version --version=4.17.0 -``` - -`uses:` takes a static ref, so `@v3` always resolves to the newest v3.x release of pup. The -branch-version check — the refusal of a version lower than the branch already has — runs on the -phar path only; the TS contract does not define it yet. +The TypeScript line of pup publishes no phar. How the action will call it is open until that +line ships a distribution: through pup's own GitHub Action, or straight from the script with the +arguments the phar takes. The manifest cannot carry the first behind a condition today, because a +composite action's `uses:` references resolve when the job is set up, not when the step runs, so +a reference to an action that does not exist yet breaks every run; the second needs something +published to call. Either way the change is local to the script, and the action's inputs and +outputs stay as they are. The branch-version check — the refusal of a version lower than the +branch already has — runs on the phar path only; the TS contract does not define it yet. ## Running it again @@ -224,8 +220,8 @@ a commit made with `GITHUB_TOKEN`. ## How it runs Two [actions/github-script](https://github.com/actions/github-script) steps run `release-prep.js`, -with the changelogger action between them. The first validates the inputs and the `.puprc`, then -runs pup. The second commits through the API. github-script supplies the Node runtime, `exec` and +with the changelogger action between them. The first validates the inputs, the `.puprc` and the +changelogger strategy, then runs pup. The second commits through the API. github-script supplies the Node runtime, `exec` and the `github` client, built from `token`; there is no dependency to install and no bundle to build. The script is loaded by path rather than inlined in the manifest because github-script's `require` diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index f59c841..fcd7524 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -189,10 +189,10 @@ function validatePupVersion(version) { /** * Resolves the `pup-version` input to a concrete pup release. * - * A pinned release number is used as it is: the download from the release page fails on its - * own when the release does not exist, so there is no API call for the common case. `latest` - * asks the GitHub API for the newest release, authenticated with the action token when the - * caller passes one, so the runner's shared unauthenticated quota is not spent. + * A pinned release number is used as it is: resolvePupDistribution checks it against the + * release page, so there is no API call for the common case. `latest` asks the GitHub API for + * the newest release, authenticated with the action token when the caller passes one, so the + * runner's shared unauthenticated quota is not spent. * * @param {object} options * @param {string} options.version The validated `pup-version` input. @@ -392,7 +392,8 @@ async function headExists(exec, url) { * @throws {Error} When the release does not exist, or a check itself fails and the run cannot * tell which path to take. * - * @returns {Promise<'phar'|'ts'>} The distribution the release carries. + * @returns {Promise<'phar'|'ts'>} The distribution the release carries: `ts` for any release + * without a phar, the old ones included, since that is the path they take. */ async function resolvePupDistribution({ exec, release }) { const asset = await headExists(exec, pupDownloadUrl(release)) @@ -596,7 +597,8 @@ async function runOrFail(exec, command, args, options, what) { * @param {string} cwd The checkout, the working directory by default. * @param {Function} fetchImpl What `fetch` to use; a test passes a stand-in. * - * @throws {Error} When an input is wrong, .puprc is unusable, or the download or pup fails. + * @throws {Error} When an input is wrong, .puprc or the changelogger versioning is unusable, the + * pup release does not exist, or the download or pup fails. * * @returns {Promise} Resolves once pup has run and the outputs are set. */ @@ -665,12 +667,12 @@ async function runPrepare({ core, exec, env, cwd = process.cwd(), fetchImpl = gl if (distribution === 'phar') { await runPrepareWithPhar({ core, exec, cwd, env, pharRelease: pupRelease, version, branch }) } else { - // The TypeScript line publishes no phar and would run through its own GitHub Action — but a - // composite action's `uses:` references resolve when the JOB is set up, not when the step - // runs, so the manifest cannot carry those calls behind a condition: they would break every - // run until the pup action exists. Until it does, a phar-less release fails here with an - // error naming the pin to use instead. The branch-version check below is part of the phar - // path — the TS contract does not define it yet. + // The TypeScript line publishes no phar, and nothing can call it yet: a composite action's + // `uses:` references resolve when the JOB is set up, not when the step runs, so the manifest + // cannot carry its action behind a condition, and calling it straight from the script needs + // a published distribution first. Until one exists, a phar-less release fails here with an + // error naming the pin to use instead. The branch-version check in runPrepareWithPhar is + // part of the phar path — the TS contract does not define it yet. throw new Error( `pup ${pupRelease} publishes no pup.phar asset, so this action cannot run it. Pin ` + 'pup-version to a release that ships one, such as 2.0.0. Nothing was changed.' @@ -765,7 +767,7 @@ async function runPrepareWithPhar({ core, exec, cwd, env, pharRelease, version, * @param {string} cwd The checkout, the working directory by default. * * @throws {Error} When a git command or an API call fails, a change is not a regular file, or - * the branch moved after the checkout. + * GitHub refuses the ref update. * * @returns {Promise} Resolves once the commit is on the branch, or found unnecessary. */ @@ -861,9 +863,9 @@ async function runCommit({ github, core, exec, env, cwd = process.cwd() }) { } catch (error) { if (error.status === 422) { throw new Error( - `${branch} moved after the checkout, so the commit would not be a fast-forward ` + - `(${error.message}). Run the step again on a fresh checkout. Nothing was committed to ` + - 'the branch.' + `GitHub refused to update ${branch} (${error.message}): the branch moved after the ` + + 'checkout, or no longer exists. Run the step again on a fresh checkout. Nothing was ' + + 'committed to the branch.' ) } diff --git a/README.md b/README.md index a7f2491..9b3ba2c 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ Each phase ends with something people can actually use. - [ ] Port `setup-slic`, `prepare-matrix`, `setup-bun` from LearnDash. - [ ] 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 and pushed to the release branch. A caller to copy is in `templates/workflows/release-prep.yml`. +- [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`. - [ ] 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-prep.yml b/templates/workflows/release-prep.yml index a77476a..dc5386f 100644 --- a/templates/workflows/release-prep.yml +++ b/templates/workflows/release-prep.yml @@ -1,7 +1,7 @@ name: Release Prep # Prepares a release branch: bumps the version, replaces the @since TBD tags and writes the -# changelog, then commits that and pushes it to the branch. No pull request is opened, so the +# changelog, then commits that to the branch through the GitHub API. No pull request is opened, so the # change is reviewed wherever the release is. # # A file to copy into .github/workflows/ of a plugin repository, not a workflow to call. The @@ -11,14 +11,14 @@ name: Release Prep # optional `date` for runs started by hand. Keep `date` optional: a tool that sends only `branch` # and `version` refuses a workflow that requires more. # -# Run it locally: you cannot, it pushes to a branch. Start it by hand with +# Run it locally: you cannot, it commits to a branch. Start it by hand with # `gh workflow run release-prep.yml -f branch=release/M26.jigglypuff -f version=4.17.0`. on: workflow_dispatch: inputs: branch: - description: 'Release branch to prepare and push to, e.g. release/M26.jigglypuff' + description: 'Release branch to prepare and commit to, e.g. release/M26.jigglypuff' required: true type: string version: @@ -32,7 +32,7 @@ on: default: '' # Two preparations of the same branch would both commit on top of the same checkout, and the second -# push would be refused. One group per branch makes the second wait for the first. +# commit would be refused as not a fast-forward. One group per branch makes the second wait for the first. concurrency: group: ${{ github.workflow }}-${{ inputs.branch }} @@ -44,13 +44,13 @@ jobs: # pup runs on PHP, which ubuntu-24.04 ships. Pinned so that stays true. runs-on: ubuntu-24.04 permissions: - contents: write # the action pushes the preparation to the branch + contents: write # the action commits the preparation to the branch steps: - uses: actions/checkout@v6 with: ref: ${{ inputs.branch }} - # The action pushes with the token it is given, so the checkout keeps no credentials in - # .git/config for a later step to find. + # The action commits through the API with the token it is given, so the checkout keeps + # no credentials in .git/config for a later step to find. persist-credentials: false # A changelog writing strategy that has to be built first, or a toolchain the repository needs, diff --git a/tests/actions/release-prep/release-prep.test.js b/tests/actions/release-prep/release-prep.test.js index b451917..7c7098c 100644 --- a/tests/actions/release-prep/release-prep.test.js +++ b/tests/actions/release-prep/release-prep.test.js @@ -583,8 +583,8 @@ describe('release-prep', () => { }) it('stops before downloading pup when the checkout is on another branch', async (t) => { - // The push writes HEAD to refs/heads/. From another branch that the release branch is an - // ancestor of, it would be accepted as a fast-forward and carry that branch's commits along. + // The commit's parent is the checkout's HEAD. From another branch that the release branch is + // an ancestor of, the ref update would be a fast-forward and carry that branch's commits along. const { core } = await coreWithOutputs(t) const { exec, calls } = recordingExec( allSucceed([{ command: 'git', args: /^symbolic-ref /, stdout: 'main\n' }]) @@ -1079,7 +1079,7 @@ describe('release-prep', () => { await assert.rejects( runCommit({ github, core, exec, env: commitEnv(), cwd: work }), - /release\/M26\.jigglypuff moved after the checkout.*Update is not a fast forward.*Nothing was committed/s + /GitHub refused to update release\/M26\.jigglypuff \(Update is not a fast forward\): the branch moved after the checkout.*Nothing was committed/s ) assert.ok(!requests.some((request) => request.body?.force), 'no ref update is forced') })