From 7848343273444e445b51ab2506fa39bed91c16c6 Mon Sep 17 00:00:00 2001 From: Paulo Iankoski Date: Wed, 4 Nov 2026 13:56:04 -0300 Subject: [PATCH 1/2] Add a shared pup action that installs pup and reads its version and zip name --- .github/actions/pup/README.md | 93 +++++++ .github/actions/pup/action.yml | 72 +++++ .github/actions/pup/pup.js | 462 +++++++++++++++++++++++++++++++++ .github/lib/commands.js | 50 ++++ tests/actions/pup/pup.test.js | 459 ++++++++++++++++++++++++++++++++ 5 files changed, 1136 insertions(+) create mode 100644 .github/actions/pup/README.md create mode 100644 .github/actions/pup/action.yml create mode 100644 .github/actions/pup/pup.js create mode 100644 .github/lib/commands.js create mode 100644 tests/actions/pup/pup.test.js diff --git a/.github/actions/pup/README.md b/.github/actions/pup/README.md new file mode 100644 index 0000000..afeab59 --- /dev/null +++ b/.github/actions/pup/README.md @@ -0,0 +1,93 @@ +# pup + +Installs [pup](https://github.com/stellarwp/pup) from its GitHub release and, when asked, reads the +version and the zip name from it. Every action in this repository that needs pup uses this one, so +pup is downloaded, checked and read the same way everywhere, and no action depends on where a +repository keeps its own copy. + +pup is a PHP archive. It is downloaded into the runner's temp directory and run with the runner's +PHP, so it never ends up in a commit or a zip. + +```yaml +- uses: actions/checkout@v7 + +- name: Install pup and read the zip name + id: pup + uses: stellarwp/plugin-toolbox/.github/actions/pup@main + with: + pup-version: '2.0.0' + read: zip-name + dev: 'true' + +- run: echo "${{ steps.pup.outputs.zip-name }}.zip" +``` + +Inside this repository, call it as `uses: $/.github/actions/pup`. + +## Inputs + +| Input | Default | What it does | +|---|---|---| +| `pup-version` | `2.0.0` | The pup release: a number such as `2.0.0`, or `latest`. See [Which pup](#which-pup) | +| `read` | empty | Empty installs only. `version` also runs `pup get-version`. `zip-name` also runs `pup zip-name` for that version. Both need a checkout | +| `dev` | `false` | `true` reads the development build version, `pup get-version --dev`, which has the commit in it. Only used when `read` is set | +| `token` | `github.token` | Authenticates the `latest` lookup. A pinned release makes no API call | + +## Outputs + +| Output | What it is | +|---|---| +| `phar` | Path of `pup.phar`. Run any pup command with `php "$PHAR" ` | +| `release` | The release that was installed. `latest` becomes its number | +| `version` | What `pup get-version` printed. Empty unless `read` is set | +| `zip-name` | The zip's name, without `.zip`. Empty unless `read` is `zip-name` | + +Both values are the last line pup printed, so a PHP notice printed before it is never part of them. +PHP's errors go to stderr (`display_errors=stderr`) for the same reason. + +## Which pup + +`pup-version` pins a release or tracks the newest one with `latest`. A pinned release downloads +straight from its release page. `latest` asks the GitHub API for the newest release. + +The release must ship a `pup.phar` asset, which every release from 1.2.4 to 2.0.0 does. The action +checks that with a HEAD request. A release without a phar fails with a message naming a pin to use +instead, and a number that is not a release at all (a typo) fails with "is not a release". + +Use the release the repository's own `composer pup` script downloads. The zip name pup makes can +depend on its release, and the name read here has to equal the name of the zip the repository's own +build made. + +Within one job the phar is downloaded once. A second use of the action, or of the script below, +finds the file and reuses it. + +## From another action's script + +`pup.js` is a plain Node module. Its functions take the `exec` toolkit, so a script that +github-script runs can pass the one it was given: + +```js +const { installPup, getVersion, zipName, runPup } = require('../pup/pup.js') + +const { phar } = await installPup({ core, exec, version: '2.0.0', token: '', env: process.env }) +const version = await getVersion({ exec, phar, cwd, dev: true }) +const name = await zipName({ exec, phar, cwd, version }) +const output = await runPup({ exec, phar, cwd, args: ['check'] }) +``` + +`runPup` runs any pup command and returns what it printed, or throws with the output when pup exits +non-zero. The module also exports `downloadPhar`, `pharPath`, `validatePupVersion`, +`resolvePupRelease`, `resolvePupDistribution`, `pupDownloadUrl` and `pupReleaseUrl`. Release prep +uses them for its own steps. + +The path is relative to the file that requires it. github-script's own `require` resolves against +the workspace of the repository being built, which is why an action's manifest passes +`${{ github.action_path }}`. + +## How it runs + +The logic is in `pup.js`, run by actions/github-script, which supplies the Node runtime and `exec`. +There is no dependency to install and no bundle to build: the file in the repository is the file +that runs. Inputs reach the script through `process.env`, never by being interpolated into it. +`tests/actions/pup/pup.test.js` runs it against a stand-in for `exec` and for `fetch`. Run +`npm install` once, then `node --test`. diff --git a/.github/actions/pup/action.yml b/.github/actions/pup/action.yml new file mode 100644 index 0000000..40c2282 --- /dev/null +++ b/.github/actions/pup/action.yml @@ -0,0 +1,72 @@ +# Installs pup, and optionally reads the version and the zip name from it. +# +# pup is downloaded from its GitHub release into the runner's temp directory and run with PHP, so a +# repository does not need a copy of it, and every action in this repository installs and reads pup +# the same way. The work is in pup.js, run by actions/github-script, so there is nothing to install +# and nothing to build. +name: pup +description: Installs pup from its GitHub release and optionally reads the version and the zip name + +inputs: + pup-version: + description: >- + The pup release to install: a release number, such as `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. Use the + release the repository's own `composer pup` script downloads: a zip name made by another + release could differ from the one the repository's build makes. + required: false + default: '2.0.0' + read: + description: >- + What to read from pup after installing it. Empty reads nothing. `version` runs + `pup get-version`. `zip-name` reads the version, then runs `pup zip-name` for it. Both need a + checkout of the repository, because pup reads the version from its files. + required: false + default: '' + dev: + description: >- + `true` reads the development build version, `pup get-version --dev`, which has the commit in + it. Only used when `read` is set. + required: false + default: 'false' + token: + description: >- + Token for the `latest` lookup through the GitHub API, so the runner's shared unauthenticated + quota is not spent. A pinned release makes no API call and needs no token. + required: false + default: ${{ github.token }} + +outputs: + phar: + description: 'Path of pup.phar. Run it with `php "$PHAR" `.' + value: ${{ steps.pup.outputs.phar }} + release: + description: 'The pup release that was installed, as a tag. `latest` is resolved to its number.' + value: ${{ steps.pup.outputs.release }} + version: + description: 'The version pup printed. Empty unless `read` is `version` or `zip-name`.' + value: ${{ steps.pup.outputs.version }} + zip-name: + description: 'The name of the zip, without `.zip`. Empty unless `read` is `zip-name`.' + value: ${{ steps.pup.outputs.zip-name }} + +runs: + using: 'composite' + steps: + - name: Install pup + id: pup + uses: actions/github-script@v9 + env: + # github-script's require() resolves against the workspace of the repository being built, + # not this action's directory, so the path has to be passed in. + ACTION_PATH: ${{ github.action_path }} + # Read from process.env rather than interpolated into the script, so an input is data and + # never part of the program. + INPUT_PUP_VERSION: ${{ inputs.pup-version }} + INPUT_READ: ${{ inputs.read }} + INPUT_DEV: ${{ inputs.dev }} + INPUT_TOKEN: ${{ inputs.token }} + with: + script: | + const { run } = require(`${process.env.ACTION_PATH}/pup.js`) + await run({ core, exec, env: process.env }) diff --git a/.github/actions/pup/pup.js b/.github/actions/pup/pup.js new file mode 100644 index 0000000..4ab02b0 --- /dev/null +++ b/.github/actions/pup/pup.js @@ -0,0 +1,462 @@ +'use strict' + +/** + * Installs pup and runs it, for every action that needs it. + * + * pup is a PHP archive published with each of its GitHub releases. This file resolves a release, + * checks that it ships a phar, downloads the phar into the runner's temp directory and runs it + * through `php`, so an action never depends on a copy of pup in the repository it runs on. The + * answers it reads back, the version and the zip name, come from the last line pup printed, so a + * PHP notice printed before it never ends up in the value. + * + * The action manifest runs `run` through actions/github-script. Another action's script calls the + * functions it needs: see this action's README. They take the `exec` toolkit as an argument, so a + * test passes a stand-in and asserts the commands. + */ + +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +const { lastLine, runOrFail } = require('../../lib/commands.js') + +/** The pup release used when none is asked for. */ +const DEFAULT_PUP_VERSION = '2.0.0' + +/** The values `read` can ask for after pup is installed. */ +const READ_VALUES = ['', 'version', 'zip-name'] + +/** A pup release number, which goes into the download URL. */ +const PUP_VERSION_PATTERN = /^\d+\.\d+\.\d+$/ + + +/** + * 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 (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: 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. + * @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.` + ) + } + + // 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 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( + 'The latest pup release has no usable tag. Pass a pinned pup-version and run this step ' + + 'again. Nothing was changed.' + ) + } + + return tag +} + +/** + * 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 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 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 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: `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)) + + if (asset.exists) { + return 'phar' + } + + 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) + } + + 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') + ) +} + +/** + * Where the phar of a pup release is kept for the rest of the job. + * + * @param {object} env The environment, for RUNNER_TEMP. + * @param {string} release The resolved pup release tag. + * + * @returns {string} The path. + */ +function pharPath(env, release) { + return path.join(env.RUNNER_TEMP || os.tmpdir(), `pup-${release}.phar`) +} + +/** + * Whether a usable phar is already in place, from an earlier step of the same job. + * + * @param {string} phar The path of the phar. + * + * @returns {boolean} True when the file exists and is not empty. + */ +function pharExists(phar) { + try { + return fs.statSync(phar).size > 0 + } catch { + return false + } +} + +/** + * Downloads the phar of a pup release, unless it is already in place. + * + * A download that fails leaves nothing behind, so a later step never takes half a file for a phar. + * + * @param {object} options + * @param {object} options.exec The @actions/exec toolkit. + * @param {string} options.release The resolved pup release tag. + * @param {string} options.phar Where to put it. + * + * @throws {Error} When the download fails, holding what curl printed. + * + * @returns {Promise} True when it downloaded, false when the phar was already there. + */ +async function downloadPhar({ exec, release, phar }) { + if (pharExists(phar)) { + return false + } + + try { + await runOrFail( + exec, + 'curl', + ['-fsSL', '--retry', '3', '-o', phar, pupDownloadUrl(release)], + {}, + `Downloading pup ${release}` + ) + } catch (error) { + fs.rmSync(phar, { force: true }) + + throw error + } + + return true +} + +/** + * Runs a pup command with PHP and returns what it printed. + * + * PHP notices go to stderr (`display_errors=stderr`), so stdout holds the answer. + * + * @param {object} options + * @param {object} options.exec The @actions/exec toolkit. + * @param {string} options.phar Path to pup.phar. + * @param {string[]} options.args The pup command and its arguments, e.g. `['get-version', '--dev']`. + * @param {string} options.cwd The checkout pup runs in, the working directory by default. + * + * @throws {Error} When pup exits non-zero, holding what it printed. + * + * @returns {Promise} What pup printed to stdout. + */ +async function runPup({ exec, phar, args, cwd = process.cwd() }) { + return runOrFail( + exec, + 'php', + ['-d', 'display_errors=stderr', phar, ...args], + { cwd, silent: true }, + `pup ${args[0]}` + ) +} + +/** + * Asks pup for the version the repository is at: `pup get-version`, or `pup get-version --dev` for + * the development build version, which has the commit in it. + * + * @param {object} options + * @param {object} options.exec The @actions/exec toolkit. + * @param {string} options.phar Path to pup.phar. + * @param {string} options.cwd The checkout. + * @param {boolean} options.dev Ask for the development build version. + * + * @throws {Error} When pup fails or prints no version. + * + * @returns {Promise} The version, the last line pup printed. + */ +async function getVersion({ exec, phar, cwd, dev = false }) { + const printed = await runPup({ exec, phar, cwd, args: dev ? ['get-version', '--dev'] : ['get-version'] }) + const version = lastLine(printed) + + if (version === '') { + throw new Error('pup get-version printed no version.') + } + + return version +} + +/** + * Asks pup for the name of the zip it makes for a version, without `.zip`. + * + * @param {object} options + * @param {object} options.exec The @actions/exec toolkit. + * @param {string} options.phar Path to pup.phar. + * @param {string} options.cwd The checkout. + * @param {string} options.version The version, as getVersion returns it. + * + * @throws {Error} When the version is empty, or pup fails or prints no name. + * + * @returns {Promise} The zip's name, the last line pup printed. + */ +async function zipName({ exec, phar, cwd, version }) { + if (String(version ?? '').trim() === '') { + throw new Error('pup zip-name needs a version.') + } + + const name = lastLine(await runPup({ exec, phar, cwd, args: ['zip-name', String(version)] })) + + if (name === '') { + throw new Error('pup zip-name printed no name.') + } + + return name +} + +/** + * Installs pup: resolves the release, checks that it ships a phar and puts the phar in place. + * + * A phar that an earlier step of the job downloaded is reused, and a pinned release then needs no + * network call at all. + * + * @param {object} options + * @param {object} options.core The @actions/core toolkit, for the log. + * @param {object} options.exec The @actions/exec toolkit. + * @param {string} options.version The `pup-version` input: a release number or `latest`. Empty + * uses DEFAULT_PUP_VERSION. + * @param {string} options.token The token for the `latest` lookup, possibly empty. + * @param {object} options.env The environment, for RUNNER_TEMP. + * @param {Function} options.fetchImpl What `fetch` to use; a test passes a stand-in. + * + * @throws {Error} When the version is not a release number, the release does not exist or ships no + * phar, or the download fails. + * + * @returns {Promise<{release: string, phar: string}>} The release tag, and the path of its phar. + */ +async function installPup({ core, exec, version, token, env, fetchImpl = globalThis.fetch }) { + const pupVersion = validatePupVersion(String(version ?? '').trim() === '' ? DEFAULT_PUP_VERSION : version) + const release = await resolvePupRelease({ version: pupVersion, token: token || '', fetchImpl }) + const phar = pharPath(env, release) + + if (pharExists(phar)) { + core.info(`Reusing pup ${release} at ${phar}.`) + + return { release, phar } + } + + const distribution = await resolvePupDistribution({ exec, release }) + if (distribution !== 'phar') { + throw new Error( + `pup ${release} publishes no pup.phar asset, so this action cannot run it. Pin pup-version to ` + + `a release that ships one, such as ${DEFAULT_PUP_VERSION}.` + ) + } + + await downloadPhar({ exec, release, phar }) + core.info(`Downloaded pup ${release} to ${phar}.`) + + return { release, phar } +} + +/** + * The entry point of the action manifest: installs pup, then reads the version and the zip name + * when it is asked to. + * + * Inputs come from INPUT_PUP_VERSION, INPUT_TOKEN, INPUT_READ (empty, `version` or `zip-name`) and + * INPUT_DEV (`true` or `false`, for the development build version). Outputs: `phar`, `release`, + * and `version` and `zip-name` when asked for. + * + * @param {object} options + * @param {object} options.core The @actions/core toolkit. + * @param {object} options.exec The @actions/exec toolkit. + * @param {object} options.env The environment the action manifest put its inputs in. + * @param {string} options.cwd The checkout pup reads the version from, the working directory + * by default. + * @param {Function} options.fetchImpl What `fetch` to use; a test passes a stand-in. + * + * @throws {Error} When an input is wrong, pup cannot be installed, or pup fails. + * + * @returns {Promise} Resolves once the outputs are set. + */ +async function run({ core, exec, env, cwd = process.cwd(), fetchImpl = globalThis.fetch }) { + const read = String(env.INPUT_READ ?? '').trim() + if (!READ_VALUES.includes(read)) { + throw new Error(`read '${read}' is not one of: version, zip-name, or empty.`) + } + + const devInput = String(env.INPUT_DEV ?? '').trim().toLowerCase() + if (!['', 'true', 'false'].includes(devInput)) { + throw new Error(`dev '${env.INPUT_DEV}' is not true or false.`) + } + const dev = devInput === 'true' + + const { release, phar } = await installPup({ + core, + exec, + version: env.INPUT_PUP_VERSION, + token: env.INPUT_TOKEN, + env, + fetchImpl, + }) + core.setOutput('phar', phar) + core.setOutput('release', release) + + if (read === '') { + return + } + + const version = await getVersion({ exec, phar, cwd, dev }) + core.setOutput('version', version) + + if (read === 'zip-name') { + core.setOutput('zip-name', await zipName({ exec, phar, cwd, version })) + } +} + +module.exports = { + run, + installPup, + downloadPhar, + runPup, + getVersion, + zipName, + pharPath, + validatePupVersion, + resolvePupRelease, + resolvePupDistribution, + pupDownloadUrl, + pupReleaseUrl, + DEFAULT_PUP_VERSION, +} diff --git a/.github/lib/commands.js b/.github/lib/commands.js new file mode 100644 index 0000000..650189c --- /dev/null +++ b/.github/lib/commands.js @@ -0,0 +1,50 @@ +'use strict' + +/** + * Helpers for running a command through the @actions/exec toolkit that several actions share. + */ + +/** + * 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) ?? '' + ) +} + +/** + * Runs a command and fails with its output when it exits non-zero. + * + * @param {object} exec The @actions/exec toolkit. + * @param {string} command The program. + * @param {string[]} args Its arguments. + * @param {object} options Options for @actions/exec. + * @param {string} what What the command does, to open the error message. + * + * @throws {Error} When the command exits non-zero, holding what it printed. + * + * @returns {Promise} What the command printed to stdout. + */ +async function runOrFail(exec, command, args, options, what) { + const result = await exec.getExecOutput(command, args, { ...options, ignoreReturnCode: true }) + + if (result.exitCode !== 0) { + const output = [result.stdout, result.stderr].map((text) => text.trim()).filter(Boolean) + + throw new Error(`${what} failed (exit code ${result.exitCode}):\n${output.join('\n')}`) + } + + return result.stdout +} + +module.exports = { lastLine, runOrFail } diff --git a/tests/actions/pup/pup.test.js b/tests/actions/pup/pup.test.js new file mode 100644 index 0000000..c62c430 --- /dev/null +++ b/tests/actions/pup/pup.test.js @@ -0,0 +1,459 @@ +'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 { + run, + installPup, + downloadPhar, + runPup, + getVersion, + zipName, + pharPath, + validatePupVersion, + resolvePupRelease, + resolvePupDistribution, + pupDownloadUrl, + pupReleaseUrl, + DEFAULT_PUP_VERSION, +} = require('../../../.github/actions/pup/pup.js') +const { lastLine, runOrFail } = require('../../../.github/lib/commands.js') +const { coreWithOutputs, recordingExec } = require('../../support/actions.js') + +const PHAR_URL = 'https://github.com/stellarwp/pup/releases/download/2.0.0/pup.phar' + +/** A fresh folder for RUNNER_TEMP, so one test's phar never reaches another. */ +function runnerTemp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'pup-test-')) +} + +/** Each call as `command arg arg…`. */ +function sequence(calls) { + return calls.map((call) => [call.command, ...call.args].join(' ')) +} + +/** A fetch that answers every call the same way, and records what it was asked. */ +function fetchAnswering(status, body) { + const calls = [] + const fetchImpl = async (url, options) => { + calls.push([url, options]) + + return { ok: status >= 200 && status < 300, status, json: async () => body } + } + + return { fetchImpl, calls } +} + +/** Rules under which the release exists, ships a phar, and pup answers as asked. */ +function pupRules({ version = '4.19.1\n', name = 'my-plugin.4.19.1-dev\n' } = {}) { + return [ + { command: 'curl', args: /^-fsSI / }, + { + command: 'curl', + args: /^-fsSL /, + effect: (args) => fs.writeFileSync(args[args.indexOf('-o') + 1], 'phar'), + }, + { command: 'php', args: /zip-name/, stdout: name }, + { command: 'php', args: /get-version/, stdout: version }, + ] +} + +describe('pup', () => { + describe('lastLine and runOrFail', () => { + it('reads the last non-empty line, after any notice', () => { + assert.equal(lastLine('PHP Notice: x\n4.19.1\n\n'), '4.19.1') + assert.equal(lastLine(''), '') + assert.equal(lastLine(undefined), '') + }) + + it('fails with what the command printed when it exits non-zero', async () => { + const { exec } = recordingExec([{ command: 'php', exitCode: 3, stdout: 'out', stderr: 'err' }]) + + await assert.rejects( + runOrFail(exec, 'php', [], {}, 'pup check'), + /pup check failed \(exit code 3\):\nout\nerr/ + ) + }) + }) + + describe('validatePupVersion', () => { + it('accepts a release number and latest', () => { + assert.equal(validatePupVersion(' 2.0.0 '), '2.0.0') + assert.equal(validatePupVersion('latest'), 'latest') + }) + + it('refuses anything else', () => { + for (const version of ['', '2.0', 'v2.0.0', '2.0.0-beta', '../2.0.0']) { + assert.throws(() => validatePupVersion(version), /pup-version/) + } + }) + }) + + describe('the release URLs', () => { + it('point at the phar and at the release page', () => { + assert.equal(pupDownloadUrl('2.0.0'), PHAR_URL) + assert.equal(pupReleaseUrl('2.0.0'), 'https://github.com/stellarwp/pup/releases/tag/2.0.0') + }) + }) + + describe('resolvePupRelease', () => { + it('uses a pinned release as it is, with no request', async () => { + const { fetchImpl, calls } = fetchAnswering(500, {}) + + assert.equal(await resolvePupRelease({ version: '2.0.0', token: '', fetchImpl }), '2.0.0') + assert.deepEqual(calls, []) + }) + + it('asks the API for the newest release, with the token when there is one', async () => { + const { fetchImpl, calls } = fetchAnswering(200, { tag_name: '2.1.0' }) + + assert.equal(await resolvePupRelease({ version: 'LATEST', token: 'ghp_x', fetchImpl }), '2.1.0') + assert.deepEqual(calls, [ + ['https://api.github.com/repos/stellarwp/pup/releases/latest', { headers: { authorization: 'Bearer ghp_x' } }], + ]) + }) + + it('sends no authorization header without a token', async () => { + const { fetchImpl, calls } = fetchAnswering(200, { tag_name: '2.1.0' }) + + await resolvePupRelease({ version: 'latest', token: '', fetchImpl }) + assert.deepEqual(calls[0][1], { headers: {} }) + }) + + it('fails when the API answers with an error', async () => { + const { fetchImpl } = fetchAnswering(403, {}) + + await assert.rejects( + resolvePupRelease({ version: 'latest', token: '', fetchImpl }), + /Reading the latest pup release failed \(HTTP 403\)/ + ) + }) + + it('fails when the release has no usable tag', async () => { + for (const body of [{}, { tag_name: 7 }, { tag_name: 'nightly' }]) { + const { fetchImpl } = fetchAnswering(200, body) + + await assert.rejects( + resolvePupRelease({ version: 'latest', token: '', fetchImpl }), + /The latest pup release has no usable tag/ + ) + } + }) + }) + + describe('resolvePupDistribution', () => { + it('is phar when the release publishes one', async () => { + const { exec, calls } = recordingExec([{ command: 'curl' }]) + + assert.equal(await resolvePupDistribution({ exec, release: '2.0.0' }), 'phar') + assert.deepEqual(sequence(calls), [`curl -fsSI ${PHAR_URL}`]) + }) + + it('is ts when the release exists but has no phar', async () => { + const { exec, calls } = recordingExec([ + { command: 'curl', args: /download/, exitCode: 22, stderr: 'curl: (22) The requested URL returned error: 404' }, + { command: 'curl', args: /tag/ }, + ]) + + assert.equal(await resolvePupDistribution({ exec, release: '3.0.0' }), 'ts') + assert.equal(calls.length, 2) + }) + + it('fails naming the release when it does not exist', async () => { + const { exec } = recordingExec([{ command: 'curl', exitCode: 22, stderr: 'curl: (22) returned error: 404' }]) + + await assert.rejects(resolvePupDistribution({ exec, release: '9.9.9' }), /pup 9\.9\.9 is not a release/) + }) + + it('fails with curl output when the check itself breaks', async () => { + const { exec } = recordingExec([{ command: 'curl', exitCode: 6, stderr: 'Could not resolve host' }]) + + await assert.rejects( + resolvePupDistribution({ exec, release: '2.0.0' }), + /Checking the pup 2\.0\.0 release failed \(exit code 6\):\nCould not resolve host/ + ) + }) + }) + + describe('downloadPhar', () => { + it('downloads with retries to the given path', async () => { + const phar = path.join(runnerTemp(), 'pup-2.0.0.phar') + const { exec, calls } = recordingExec(pupRules()) + + assert.equal(await downloadPhar({ exec, release: '2.0.0', phar }), true) + assert.deepEqual(sequence(calls), [`curl -fsSL --retry 3 -o ${phar} ${PHAR_URL}`]) + }) + + it('does nothing when the phar is already there', async () => { + const phar = path.join(runnerTemp(), 'pup-2.0.0.phar') + fs.writeFileSync(phar, 'phar') + const { exec, calls } = recordingExec([]) + + assert.equal(await downloadPhar({ exec, release: '2.0.0', phar }), false) + assert.deepEqual(calls, []) + }) + + it('downloads again when the file is empty', async () => { + const phar = path.join(runnerTemp(), 'pup-2.0.0.phar') + fs.writeFileSync(phar, '') + const { exec, calls } = recordingExec(pupRules()) + + assert.equal(await downloadPhar({ exec, release: '2.0.0', phar }), true) + assert.equal(calls.length, 1) + }) + + it('fails with curl output, and leaves no half file behind', async () => { + const phar = path.join(runnerTemp(), 'pup-2.0.0.phar') + const { exec } = recordingExec([ + { + command: 'curl', + exitCode: 56, + stderr: 'curl: (56) connection reset', + effect: (args) => fs.writeFileSync(args[args.indexOf('-o') + 1], 'half'), + }, + ]) + + await assert.rejects( + downloadPhar({ exec, release: '2.0.0', phar }), + /Downloading pup 2\.0\.0 failed \(exit code 56\):\ncurl: \(56\) connection reset/ + ) + assert.equal(fs.existsSync(phar), false) + }) + }) + + describe('pharPath', () => { + it('is in RUNNER_TEMP, named after the release', () => { + assert.equal(pharPath({ RUNNER_TEMP: '/runner/temp' }, '2.0.0'), '/runner/temp/pup-2.0.0.phar') + }) + + it('falls back to the system temp directory', () => { + assert.equal(pharPath({}, '2.0.0'), path.join(os.tmpdir(), 'pup-2.0.0.phar')) + }) + }) + + describe('runPup', () => { + it('runs the phar with php, in the checkout, with errors on stderr', async () => { + const { exec, calls } = recordingExec([{ command: 'php', stdout: 'ok' }]) + + assert.equal(await runPup({ exec, phar: '/p/pup.phar', cwd: '/repo', args: ['check'] }), 'ok') + assert.deepEqual(sequence(calls), ['php -d display_errors=stderr /p/pup.phar check']) + assert.equal(calls[0].options.cwd, '/repo') + assert.equal(calls[0].options.silent, true) + }) + + it('fails with the output of pup', async () => { + const { exec } = recordingExec([{ command: 'php', exitCode: 1, stdout: 'No version found' }]) + + await assert.rejects( + runPup({ exec, phar: '/p/pup.phar', cwd: '/repo', args: ['get-version'] }), + /pup get-version failed \(exit code 1\):\nNo version found/ + ) + }) + }) + + describe('getVersion', () => { + it('reads the last line pup printed', async () => { + const { exec, calls } = recordingExec([{ command: 'php', stdout: 'PHP Notice: x\n4.19.1\n' }]) + + assert.equal(await getVersion({ exec, phar: '/p/pup.phar', cwd: '/repo' }), '4.19.1') + assert.deepEqual(sequence(calls), ['php -d display_errors=stderr /p/pup.phar get-version']) + }) + + it('asks for the development version with dev', async () => { + const { exec, calls } = recordingExec([{ command: 'php', stdout: '4.19.1-dev.abc123\n' }]) + + assert.equal(await getVersion({ exec, phar: '/p/pup.phar', cwd: '/repo', dev: true }), '4.19.1-dev.abc123') + assert.deepEqual(sequence(calls), ['php -d display_errors=stderr /p/pup.phar get-version --dev']) + }) + + it('fails when pup prints nothing', async () => { + const { exec } = recordingExec([{ command: 'php', stdout: '\n' }]) + + await assert.rejects(getVersion({ exec, phar: '/p/pup.phar', cwd: '/repo' }), /printed no version/) + }) + }) + + describe('zipName', () => { + it('asks pup for the name of the version', async () => { + const { exec, calls } = recordingExec([{ command: 'php', stdout: 'my-plugin.4.19.1-dev\n' }]) + + assert.equal( + await zipName({ exec, phar: '/p/pup.phar', cwd: '/repo', version: '4.19.1-dev.abc123' }), + 'my-plugin.4.19.1-dev' + ) + assert.deepEqual(sequence(calls), ['php -d display_errors=stderr /p/pup.phar zip-name 4.19.1-dev.abc123']) + }) + + it('refuses an empty version before running anything', async () => { + const { exec, calls } = recordingExec([]) + + await assert.rejects(zipName({ exec, phar: '/p/pup.phar', cwd: '/repo', version: ' ' }), /needs a version/) + assert.deepEqual(calls, []) + }) + + it('fails when pup prints nothing', async () => { + const { exec } = recordingExec([{ command: 'php', stdout: '' }]) + + await assert.rejects( + zipName({ exec, phar: '/p/pup.phar', cwd: '/repo', version: '1.0.0' }), + /printed no name/ + ) + }) + }) + + describe('installPup', () => { + it('checks the release, downloads the phar and returns where it is', async (t) => { + const { core, logged } = await coreWithOutputs(t) + const env = { RUNNER_TEMP: runnerTemp() } + const { exec, calls } = recordingExec(pupRules()) + + const result = await installPup({ core, exec, version: '2.0.0', token: '', env }) + + assert.deepEqual(result, { release: '2.0.0', phar: path.join(env.RUNNER_TEMP, 'pup-2.0.0.phar') }) + assert.deepEqual(sequence(calls), [ + `curl -fsSI ${PHAR_URL}`, + `curl -fsSL --retry 3 -o ${result.phar} ${PHAR_URL}`, + ]) + assert.match(logged(), /Downloaded pup 2\.0\.0/) + }) + + it('uses the default release when none is given', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec } = recordingExec(pupRules()) + + const result = await installPup({ core, exec, version: '', token: '', env: { RUNNER_TEMP: runnerTemp() } }) + + assert.equal(result.release, DEFAULT_PUP_VERSION) + }) + + it('reuses a phar from an earlier step, and asks the network for nothing', async (t) => { + const { core, logged } = await coreWithOutputs(t) + const env = { RUNNER_TEMP: runnerTemp() } + fs.writeFileSync(path.join(env.RUNNER_TEMP, 'pup-2.0.0.phar'), 'phar') + const { exec, calls } = recordingExec([]) + + await installPup({ core, exec, version: '2.0.0', token: '', env }) + + assert.deepEqual(calls, []) + assert.match(logged(), /Reusing pup 2\.0\.0/) + }) + + it('resolves latest, then names the phar after the release it found', async (t) => { + const { core } = await coreWithOutputs(t) + const env = { RUNNER_TEMP: runnerTemp() } + const { exec } = recordingExec(pupRules()) + const { fetchImpl } = fetchAnswering(200, { tag_name: '2.0.0' }) + + const result = await installPup({ core, exec, version: 'latest', token: 't', env, fetchImpl }) + + assert.equal(result.release, '2.0.0') + assert.equal(path.basename(result.phar), 'pup-2.0.0.phar') + }) + + it('refuses a release that publishes no phar, naming a pin', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec([ + { command: 'curl', args: /download/, exitCode: 22, stderr: '404' }, + { command: 'curl', args: /tag/ }, + ]) + + await assert.rejects( + installPup({ core, exec, version: '3.0.0', token: '', env: { RUNNER_TEMP: runnerTemp() } }), + /pup 3\.0\.0 publishes no pup\.phar asset.*such as 2\.0\.0/s + ) + assert.ok(!sequence(calls).some((call) => call.startsWith('curl -fsSL')), 'nothing was downloaded') + }) + + it('refuses a version that is not a release number before any request', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec([]) + + await assert.rejects( + installPup({ core, exec, version: '2.0', token: '', env: { RUNNER_TEMP: runnerTemp() } }), + /pup-version '2\.0' is not a pup release number/ + ) + assert.deepEqual(calls, []) + }) + }) + + describe('run', () => { + function inputs(overrides = {}) { + return { INPUT_PUP_VERSION: '2.0.0', INPUT_READ: '', INPUT_DEV: 'false', RUNNER_TEMP: runnerTemp(), ...overrides } + } + + it('installs pup and sets the phar and the release', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(pupRules()) + const env = inputs() + + await run({ core, exec, env, cwd: '/repo' }) + + assert.deepEqual(outputs(), { phar: path.join(env.RUNNER_TEMP, 'pup-2.0.0.phar'), release: '2.0.0' }) + assert.ok(!calls.some((call) => call.command === 'php'), 'pup was not run') + }) + + it('reads the version with read=version', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(pupRules()) + + await run({ core, exec, env: inputs({ INPUT_READ: 'version' }), cwd: '/repo' }) + + assert.equal(outputs().version, '4.19.1') + assert.equal(outputs()['zip-name'], undefined) + assert.ok(sequence(calls).some((call) => / get-version$/.test(call))) + }) + + it('reads the version and the zip name with read=zip-name, for the dev version', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + pupRules({ version: '4.19.1-dev.abc123\n', name: 'my-plugin.4.19.1-dev.abc123\n' }) + ) + + await run({ core, exec, env: inputs({ INPUT_READ: 'zip-name', INPUT_DEV: 'true' }), cwd: '/repo' }) + + assert.equal(outputs().version, '4.19.1-dev.abc123') + assert.equal(outputs()['zip-name'], 'my-plugin.4.19.1-dev.abc123') + assert.deepEqual( + sequence(calls).filter((call) => call.startsWith('php ')), + [ + `php -d display_errors=stderr ${outputs().phar} get-version --dev`, + `php -d display_errors=stderr ${outputs().phar} zip-name 4.19.1-dev.abc123`, + ] + ) + assert.ok(calls.filter((call) => call.command === 'php').every((call) => call.options.cwd === '/repo')) + }) + + it('refuses a read it does not know, before any request', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec([]) + + await assert.rejects(run({ core, exec, env: inputs({ INPUT_READ: 'both' }) }), /read 'both' is not one of/) + assert.deepEqual(calls, []) + }) + + it('refuses a dev that is not true or false', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec([]) + + await assert.rejects(run({ core, exec, env: inputs({ INPUT_DEV: 'yes' }) }), /dev 'yes' is not true or false/) + assert.deepEqual(calls, []) + }) + + it('fails when pup fails while reading the version', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec } = recordingExec([ + ...pupRules().slice(0, 2), + { command: 'php', exitCode: 1, stdout: 'Could not find version in file plugin.php' }, + ]) + + await assert.rejects( + run({ core, exec, env: inputs({ INPUT_READ: 'version' }), cwd: '/repo' }), + /pup get-version failed.*Could not find version in file plugin\.php/s + ) + }) + }) +}) From f41fc2c7f880cf473deaadc0e25b5fe64e182246 Mon Sep 17 00:00:00 2001 From: Paulo Iankoski Date: Wed, 4 Nov 2026 13:56:04 -0300 Subject: [PATCH 2/2] Make Release Prep use the shared pup action's script --- .github/actions/release-prep/README.md | 4 + .github/actions/release-prep/release-prep.js | 251 ++----------------- 2 files changed, 18 insertions(+), 237 deletions(-) diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md index 6df5f8f..a6d0ef5 100644 --- a/.github/actions/release-prep/README.md +++ b/.github/actions/release-prep/README.md @@ -190,6 +190,10 @@ 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 download, the release checks and the way pup is run come from the shared [pup](../pup/) +action's script, so every action in this repository installs and reads pup the same way. A phar +that an earlier step of the job already downloaded is reused. + 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. diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index 1729801..7af7ff4 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -12,11 +12,21 @@ */ const fs = require('node:fs') -const os = require('node:os') const path = require('node:path') const { execFileSync } = require('node:child_process') +const { lastLine, runOrFail } = require('../../lib/commands.js') const { compareVersions } = require('../../lib/versions.js') +const { + validatePupVersion, + resolvePupRelease, + resolvePupDistribution, + pupDownloadUrl, + pupReleaseUrl, + pharPath, + downloadPhar, + runPup, +} = require('../pup/pup.js') /** * Three or four numeric parts, the shape the stellarwp changelog versioning accepts. That strategy @@ -37,9 +47,6 @@ const SEMVER_PATTERN = /** 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+$/ - /** A calendar date as the changelog writing strategies expect it. */ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ @@ -232,77 +239,6 @@ function validateBranch(branch, prefixes = []) { 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 (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: 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. - * @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.` - ) - } - - // 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 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( - 'The latest pup release has no usable tag. Pass a pinned pup-version and run this step ' + - 'again. Nothing was changed.' - ) - } - - return tag -} - /** * Reads what the action needs from the repository's .puprc. * @@ -396,110 +332,6 @@ function readChangeloggerStrategy(text) { ) } -/** - * 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 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 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 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: `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)) - - if (asset.exists) { - return 'phar' - } - - 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) - } - - 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') - ) -} - /** * The pup commands that prepare a version, in the order they run. * @@ -558,24 +390,6 @@ function commitMessage(branch, version) { return `Prepare ${branch} (${version})` } -/** - * 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. @@ -623,31 +437,6 @@ function readCheckoutFile(cwd, name) { } } -/** - * Runs a command and fails with its output when it exits non-zero. - * - * @param {object} exec The @actions/exec toolkit. - * @param {string} command The program. - * @param {string[]} args Its arguments. - * @param {object} options Options for @actions/exec. - * @param {string} what What the command does, to open the error message. - * - * @throws {Error} When the command exits non-zero, holding what it printed. - * - * @returns {Promise} What the command printed to stdout. - */ -async function runOrFail(exec, command, args, options, what) { - const result = await exec.getExecOutput(command, args, { ...options, ignoreReturnCode: true }) - - if (result.exitCode !== 0) { - const output = [result.stdout, result.stderr].map((text) => text.trim()).filter(Boolean) - - throw new Error(`${what} failed (exit code ${result.exitCode}):\n${output.join('\n')}`) - } - - return result.stdout -} - /** * Validates the inputs, then bumps the version and replaces the TBDs with pup. * @@ -766,26 +555,14 @@ async function runPrepare({ core, exec, env, cwd = process.cwd(), fetchImpl = gl * @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`) + const phar = pharPath(env, pharRelease) - await runOrFail( - exec, - 'curl', - ['-fsSL', '--retry', '3', '-o', phar, pupDownloadUrl(pharRelease)], - {}, - `Downloading pup ${pharRelease}` - ) + await downloadPhar({ exec, release: pharRelease, phar }) // 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. 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 printed = await runPup({ exec, phar, cwd, args: ['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.