From 9d96f9b4c656e1736f2526404d4ba2a605b18e73 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Wed, 4 Nov 2026 13:52:03 -0300 Subject: [PATCH 01/19] Share how pup is resolved, downloaded and run: .github/lib/pup.js release-prep resolved pup-version, checked the release for its phar, downloaded it and ran it from its own script. The zip actions need the same, so that part moves to .github/lib/pup.js, the one place that knows how pup is distributed. It hands back a pup(args) function; the actions call that, and when the TypeScript line of pup publishes a distribution only this file changes. The commands release-prep runs, and the errors it prints, are the same as before. --- .github/actions/release-prep/release-prep.js | 247 ++------------ .github/lib/pup.js | 329 +++++++++++++++++++ 2 files changed, 351 insertions(+), 225 deletions(-) create mode 100644 .github/lib/pup.js diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index ab09ca8..ee09113 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -12,7 +12,6 @@ */ const fs = require('node:fs') -const os = require('node:os') const path = require('node:path') const { execFileSync } = require('node:child_process') @@ -20,6 +19,18 @@ const { execFileSync } = require('node:child_process') const { compareVersions, STELLARWP_VERSION_PATTERN } = require('../../lib/versions.js') const { readRepository, tagExists } = require('../../lib/github.js') const { runOrFail } = require('../../lib/exec.js') +// How pup is resolved, downloaded and run lives with the other actions that run it. +const { + validatePupVersion, + resolvePupRelease, + resolvePupDistribution, + pupDownloadUrl, + pupReleaseUrl, + noPharError, + downloadPhar, + pupRunner, + lastLine, +} = require('../../lib/pup.js') /** * A semver version as semver.org defines it: MAJOR.MINOR.PATCH, an optional pre-release and @@ -33,9 +44,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}$/ @@ -228,77 +236,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. * @@ -392,110 +329,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. * @@ -554,24 +387,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. @@ -736,16 +551,9 @@ async function runPrepare({ github, core, exec, env, cwd = process.cwd(), fetchI if (distribution === 'phar') { await runPrepareWithPhar({ core, exec, cwd, env, pharRelease: pupRelease, version, branch }) } else { - // 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.' - ) + // Nothing can call the TypeScript line yet; see noPharError. The branch-version check in + // runPrepareWithPhar is part of the phar path — the TS contract does not define it yet. + throw noPharError(pupRelease, 'Nothing was changed.') } core.setOutput('version', version) @@ -769,26 +577,13 @@ async function runPrepare({ github, core, exec, env, cwd = process.cwd(), fetchI * @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(pharRelease)], - {}, - `Downloading pup ${pharRelease}` - ) + const phar = await downloadPhar({ exec, env, release: pharRelease }) + const pup = pupRunner(exec, 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 pup(['get-version'], { cwd, silent: true, noticesToStderr: true }) 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. @@ -811,8 +606,10 @@ async function runPrepareWithPhar({ core, exec, cwd, env, pharRelease, version, ) } - for (const { command, args } of pupCommands(phar, version)) { - await runOrFail(exec, command, args, { cwd }, `pup ${args[1]}`) + for (const { args } of pupCommands(phar, version)) { + // Each entry names the phar first, as the command line runs it; pup() adds that itself. + const [, ...pupArgs] = args + await pup(pupArgs, { cwd }) } } diff --git a/.github/lib/pup.js b/.github/lib/pup.js new file mode 100644 index 0000000..730b5cf --- /dev/null +++ b/.github/lib/pup.js @@ -0,0 +1,329 @@ +'use strict' + +/** + * Running pup, shared by the toolbox actions. Loaded by a path relative to each action's directory, + * like versions.js. + * + * This is the one place that knows how pup is distributed: a `pup.phar` asset on each GitHub + * release, run on the runner's PHP. `pup-version` is resolved here (a pinned release, or `latest` + * through the API), the release is checked for the asset, the phar is downloaded into the runner's + * temp directory, and the actions get back a `pup(args)` function. When the TypeScript line of pup + * publishes a distribution, this file changes and the actions run it without an edit of their own. + * What this file hides is the distribution, not the CLI: a command or a flag renamed in pup still + * means a change in the actions that call it. + */ + +const os = require('node:os') +const path = require('node:path') + +const { runOrFail } = require('./exec.js') + +/** 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. + * @param {string} options.what What was not done, to close the error message. + * + * @throws {Error} When the latest release cannot be read. + * + * @returns {Promise} The release tag to download from. + */ +async function resolvePupRelease({ version, token, fetchImpl, what = 'Nothing was changed.' }) { + 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. ${what}` + ) + } + + // 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. ${what}` + ) + } + + 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 actions run. 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} options + * @param {object} options.exec The @actions/exec toolkit. + * @param {string} options.release The resolved pup release tag. + * @param {string} options.what What was not done, to close the error message. + * + * @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, what = 'Nothing was changed.' }) { + 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. ${what}`) + } + + 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 error for a release that exists but publishes no phar. + * + * 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 a 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 with an error naming the pin to use instead. + * + * @param {string} release The pup release tag. + * @param {string} what What was not done, to close the error message. + * + * @returns {Error} The error. + */ +function noPharError(release, what) { + return 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 2.0.0. ${what}` + ) +} + +/** + * Downloads the phar of a pup release into the runner's temp directory, so it never ends up in the + * checkout or in a commit. + * + * @param {object} options + * @param {object} options.exec The @actions/exec toolkit. + * @param {object} options.env The step environment, for RUNNER_TEMP. + * @param {string} options.release The resolved pup release tag. + * + * @throws {Error} When the download fails. + * + * @returns {Promise} The path of the downloaded phar. + */ +async function downloadPhar({ exec, env, release }) { + const phar = path.join(env.RUNNER_TEMP || os.tmpdir(), `pup-${release}.phar`) + + await runOrFail(exec, 'curl', ['-fsSL', '--retry', '3', '-o', phar, pupDownloadUrl(release)], {}, `Downloading pup ${release}`) + + return phar +} + +/** + * The function the actions run pup through: `pup(['package', version], { cwd })`. + * + * The phar runs on the runner's PHP, with the arguments as a list and no shell. A command whose + * output the action reads passes `noticesToStderr`, which sends PHP's notices and deprecations to + * stderr so the parsed stdout holds only what pup printed. + * + * @param {object} exec The @actions/exec toolkit. + * @param {string} phar Path to the downloaded pup.phar. + * + * @returns {Function} `pup(args, options, what)`: runs pup with `args`, the @actions/exec `options` + * plus `noticesToStderr`, and fails with pup's output when it exits non-zero; `what` opens + * that message, `pup ` by default. Resolves to what pup printed to stdout. + */ +function pupRunner(exec, phar) { + return async (args, options = {}, what = `pup ${args[0]}`) => { + const { noticesToStderr = false, ...execOptions } = options + const php = noticesToStderr ? ['-d', 'display_errors=stderr', phar, ...args] : [phar, ...args] + + return runOrFail(exec, 'php', php, execOptions, what) + } +} + +/** + * Resolves, checks and downloads pup, and hands back the function to run it: everything an action + * does before its first pup command. + * + * @param {object} options + * @param {object} options.exec The @actions/exec toolkit. + * @param {object} options.env The step environment: INPUT_PUP_VERSION, INPUT_TOKEN and + * RUNNER_TEMP. + * @param {Function} options.fetchImpl What `fetch` to use for `latest`; a test passes a stand-in. + * @param {string} options.what What was not done, to close each error message. + * + * @throws {Error} When `pup-version` is not a release number, the release cannot be resolved, does + * not exist or publishes no phar, or the download fails. + * + * @returns {Promise<{release: string, pup: Function}>} The release that runs and the function to + * run it with, as pupRunner builds it. + */ +async function setupPup({ exec, env, fetchImpl = globalThis.fetch, what = 'Nothing was changed.' }) { + const release = await resolvePupRelease({ + version: validatePupVersion(env.INPUT_PUP_VERSION), + token: env.INPUT_TOKEN || '', + fetchImpl, + what, + }) + + if ((await resolvePupDistribution({ exec, release, what })) !== 'phar') { + throw noPharError(release, what) + } + + const phar = await downloadPhar({ exec, env, release }) + + return { release, pup: pupRunner(exec, phar) } +} + +/** + * The last non-empty line of a command's output, where `pup get-version` and `pup zip-name` print + * their answer 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) ?? '' + ) +} + +module.exports = { + PUP_VERSION_PATTERN, + validatePupVersion, + resolvePupRelease, + pupDownloadUrl, + pupReleaseUrl, + resolvePupDistribution, + noPharError, + downloadPhar, + pupRunner, + setupPup, + lastLine, +} From da248a46b9068e2c1e14efcacad7d57d4fe9c24e Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Wed, 4 Nov 2026 14:01:07 -0300 Subject: [PATCH 02/19] Add zip-name: the zip's name before the build, with a pre-release label and a build number The version the checkout holds, from pup get-version; a suffix for a QA build; the slug from pup zip-name. The suffix is -dev-- by default, built from the same two git commands pup's DevSuffix.php runs, so a default build keeps the name it has today; alpha, beta and rc take the place of dev, and a sequence takes the place of the timestamp and the hash, for a build numbered by hand. With production yes the two are ignored with a warning, never refused. Every input check and the .puprc read run before pup is downloaded. expected-version prints VERSION_MISMATCH on a line of its own and fails, where the release dashboard looks for it. recordingExec takes stdout as a function of the arguments, for pup zip-name's echo of the version. --- .github/actions/zip-name/README.md | 164 ++++++++ .github/actions/zip-name/action.yml | 94 +++++ .github/actions/zip-name/zip-name.js | 304 +++++++++++++++ tests/actions/zip-name/zip-name.test.js | 492 ++++++++++++++++++++++++ tests/support/actions.js | 9 +- tests/support/actions.test.js | 8 + 6 files changed, 1069 insertions(+), 2 deletions(-) create mode 100644 .github/actions/zip-name/README.md create mode 100644 .github/actions/zip-name/action.yml create mode 100644 .github/actions/zip-name/zip-name.js create mode 100644 tests/actions/zip-name/zip-name.test.js diff --git a/.github/actions/zip-name/README.md b/.github/actions/zip-name/README.md new file mode 100644 index 0000000..492c1a2 --- /dev/null +++ b/.github/actions/zip-name/README.md @@ -0,0 +1,164 @@ +# zip-name + +Resolves the name of the zip a build will produce, before the build: the version the checkout +holds, the pre-release suffix of a QA build, and the slug pup gives the zip. + +```yaml +- uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + +- uses: stellarwp/plugin-toolbox/.github/actions/zip-name@v1 + id: name + with: + production: ${{ inputs.production }} + prerelease: ${{ inputs.prerelease }} + +# Ask the bucket for ${{ steps.name.outputs.zip-name }}.zip here, and skip the build when it has it. + +- uses: stellarwp/plugin-toolbox/.github/actions/release-zip@v1 + with: + version: ${{ steps.name.outputs.version }} + production: ${{ inputs.production }} +``` + +The name is known before the build so a caller can ask the S3 bucket whether that zip already +exists and skip the build, which is how the Slack packaging bot works today. That is why naming and +building are two actions. [`templates/workflows/zip.yml`](../../../templates/workflows/zip.yml) is a +complete workflow around the two, ready to copy into a plugin repository. + +## What it names + +| `production` | `prerelease` | `sequence` | `version` | `zip-name` | +|---|---|---|---|---| +| `yes` | ignored | ignored | `4.19.0.1` | `give.4.19.0.1` | +| `no` | `dev` | | `4.19.0.1-dev-1792689067-fbcf8681` | `give.4.19.0.1-dev-1792689067-fbcf8681` | +| `no` | `rc` | | `4.19.0.1-rc-1792689067-fbcf8681` | `give.4.19.0.1-rc-1792689067-fbcf8681` | +| `no` | `rc` | `2` | `4.19.0.1-rc-2` | `give.4.19.0.1-rc-2` | + +The base version is what `pup get-version` reads: the first file `.puprc` lists under +`paths.versions`. The slug is what `pup zip-name` gives: `zip_name` in `.puprc`, or without one the +name in `composer.json` with the vendor removed. pup is asked for both, so the rules live in one +place. + +The suffix is the build's, not the version's. The version on the branch, in the tag and in the +GitHub release stays `4.19.0.1`; the suffix exists in the zip name and, once release-zip has run, in +the version files inside the zip. A `dev` build with no `sequence` carries `-dev--`, +the suffix `pup get-version --dev` gives: the committer timestamp of HEAD and its 8-character hash, +read with the same two git commands pup runs, so a default QA build is named as every QA build was +until now. The other labels take the place of `dev`. A `sequence` takes the place of the timestamp +and the hash, for a build numbered by hand, `rc-1`, `rc-2`; nothing counts up on its own, since +that would need state somewhere. + +Hyphens throughout, as pup writes them. In PHP's `version_compare`, which WordPress uses to decide +whether an update applies, `-`, `.` and `_` are the same separator and `dev < alpha < beta < RC < +final`, so `4.19.0.1-rc-1 < 4.19.0.1` and a site running the RC gets the final release. Never `+`: +in a URL it is a space, and the zip links break. + +## What the repository needs + +A `.puprc` with the version files and the zip name: + +```json +{ + "zip_name": "give", + "paths": { + "versions": [ + { "file": "give.php", "regex": "(Version: )(.+)" }, + { "file": "give.php", "regex": "(define\\( 'GIVE_VERSION', ')([^']+)" }, + { "file": "readme.txt", "strategy": "(Stable tag: )(.+)" } + ] + } +} +``` + +`zip_name` may be left out when `composer.json` has a `name`; pup takes the part after the slash. +Without either, `pup zip-name` fails and so does the action. + +The checkout has to be on the ref to build, with its commit: the action reads `.puprc` and the +version files from the working tree, and the commit's timestamp and hash from git. The template +checks out `ref` first. + +## What it refuses + +Before anything is downloaded or run: + +- `production` that is not `yes` or `no`. The template declares it as a `choice` of the two, but the + action checks the value itself, so a dispatch by hand or by a tool gets the same answer. +- `prerelease` that is not `dev`, `alpha`, `beta` or `rc`. Empty counts as `dev`. +- `sequence` that is not empty or digits from 1 with no leading zero: `0`, `02`, `abc`, `rc-2`. +- A missing `.puprc`, one that is not JSON, or one with no `paths.versions`. + +After the version is read, before the suffix is built: + +- `expected-version` that differs from the version the files hold. The action prints + `VERSION_MISMATCH: expected 4.19.0.2, but the version files hold 4.19.0.1.` on a line of its own, + where the release dashboard looks for it, and fails. The build was asked for a ref that is not the + one prepared for that version, or the preparation did not run. +- A version pup could not read: an empty answer, or `unknown`, which pup prints when the regex of + the first version file matches nothing. + +And at the end, a name from `pup zip-name` that does not end in `.`, which means the name +is not the one the S3 check and the upload would look for. + +With `production: yes`, `prerelease` and `sequence` are ignored, not refused: the action writes a +warning saying so, and names the clean zip. `production` is the master switch; a form filled in by +habit still produces the right production build. + +## Inputs + +| Input | Required | Default | What it does | +|---|---|---|---| +| `production` | no | `no` | `yes` names the clean zip and ignores the two below; `no` names a QA build | +| `prerelease` | no | `dev` | The label of a QA build: `dev`, `alpha`, `beta` or `rc` | +| `sequence` | no | | The number of a build numbered by hand, in place of the timestamp and hash | +| `expected-version` | no | | The version the files have to hold; see [What it refuses](#what-it-refuses) | +| `pup-version` | no | `latest` | The pup release to run: a number such as `2.0.0`, or `latest`. See [Which pup runs](#which-pup-runs) | +| `token` | no | `github.token` | Authenticates the `latest` lookup. A pinned `pup-version` never uses it | + +## Outputs + +| Output | Example | What it is | +|---|---|---| +| `base-version` | `4.19.0.1` | The version the files hold | +| `version` | `4.19.0.1-rc-1792689067-fbcf8681` | The version the build carries, suffix included. Pass it to release-zip | +| `zip-name` | `give.4.19.0.1-rc-1792689067-fbcf8681` | The zip name without `.zip` | +| `prerelease` | `rc` | The label the build carries. Empty for a production build, so a step can read `== 'dev'` without looking at `production` | + +## 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. + +The release itself decides whether the action can run it: 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 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". + +How pup is resolved, downloaded and run is shared with release-prep and release-zip, in +[`.github/lib/pup.js`](../../lib/pup.js). When the TypeScript line of pup publishes a distribution, +that file changes and this action runs it without an edit of its own. Pass release-zip the same +`pup-version`, so the zip is named by the pup that read the version. + +## How it runs + +One [actions/github-script](https://github.com/actions/github-script) step runs +[`zip-name.js`](./zip-name.js). 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 }}`. + +Inputs reach the script through `process.env`, never by being interpolated into it, so a version +or a label is data and cannot become part of the program. The commands run with their arguments as +a list, without a shell. + +`tests/actions/zip-name/zip-name.test.js` covers the rules above against a stand-in for `exec`, +and one test runs the two git reads for real, against a repository made for it, to fix that the +`dev` suffix is the one `DevSuffix.php` would build from the same commit. Run `npm install` once, +then `node --test`. diff --git a/.github/actions/zip-name/action.yml b/.github/actions/zip-name/action.yml new file mode 100644 index 0000000..c62d6fa --- /dev/null +++ b/.github/actions/zip-name/action.yml @@ -0,0 +1,94 @@ +# Resolves the name of the zip a build will produce, before the build: the version the checkout +# holds, the pre-release suffix of a QA build, and the slug pup gives the zip. A caller uses the +# name to ask the S3 bucket whether the zip already exists and skip the build, then hands the +# version to release-zip, which builds and packages it. +# +# The checks live in zip-name.js, run by actions/github-script, so there is no dependency to install +# and nothing to build. The version and the slug come from pup itself, downloaded into the runner's +# temp directory, so the rules in .puprc are read once, by the tool that packages. +name: Zip name +description: Resolves the version and the zip name a build will produce, with a pre-release suffix for a QA build + +inputs: + production: + description: >- + `yes` for a production build, named `..zip` with the version files untouched; + `no` for a QA build, whose version carries a pre-release suffix. With `yes`, `prerelease` and + `sequence` are ignored with a warning. Anything else fails before anything runs. + required: false + default: 'no' + prerelease: + description: >- + The label a QA build is named with: `dev`, `alpha`, `beta` or `rc`. `dev` with no `sequence` + is `-dev--`, the suffix `pup get-version --dev` gives, so the default name is + the one every QA build had until now. Anything else fails before anything runs. + required: false + default: 'dev' + sequence: + description: >- + The number of a build numbered by hand, digits from 1 with no leading zero: `2` names the build + `-rc-2`. Empty names it after the commit, `-rc--`, unique + per commit. Anything else fails before anything runs. + required: false + default: '' + expected-version: + description: >- + When set, the version the files .puprc lists have to hold, before any suffix. A different + version prints `VERSION_MISMATCH` on a line of its own and fails the step, so a build of the + wrong ref stops before it starts. + required: false + default: '' + pup-version: + description: >- + The pup release that reads the version and names the zip: 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; a release without one fails the step and names a pin to use instead. + required: false + default: 'latest' + token: + description: >- + Authenticates the 'latest' lookup of pup-version against the GitHub API, so the runner's shared + quota is not spent. A pinned pup-version never uses it. + required: false + default: ${{ github.token }} + +outputs: + base-version: + description: 'The version the files .puprc lists hold, e.g. `4.19.0.1`.' + value: ${{ steps.name.outputs.base-version }} + version: + description: >- + The version the build carries: the base version for a production build, the base version with + the pre-release suffix for a QA build, e.g. `4.19.0.1-rc-1792689067-fbcf8681`. Pass it to + release-zip. + value: ${{ steps.name.outputs.version }} + zip-name: + description: 'The zip name without `.zip`, as `pup zip-name` gives it, e.g. `give.4.19.0.1-rc-1792689067-fbcf8681`.' + value: ${{ steps.name.outputs.zip-name }} + prerelease: + description: 'The pre-release label the build carries: `dev`, `alpha`, `beta` or `rc`. Empty for a production build.' + value: ${{ steps.name.outputs.prerelease }} + +runs: + using: 'composite' + steps: + - name: Check the inputs, read the version and name the zip + id: name + 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_PRODUCTION: ${{ inputs.production }} + INPUT_PRERELEASE: ${{ inputs.prerelease }} + INPUT_SEQUENCE: ${{ inputs.sequence }} + INPUT_EXPECTED_VERSION: ${{ inputs.expected-version }} + INPUT_PUP_VERSION: ${{ inputs.pup-version }} + INPUT_TOKEN: ${{ inputs.token }} + with: + script: | + const { runZipName } = require(`${process.env.ACTION_PATH}/zip-name.js`) + await runZipName({ core, exec, env: process.env }) diff --git a/.github/actions/zip-name/zip-name.js b/.github/actions/zip-name/zip-name.js new file mode 100644 index 0000000..671fe78 --- /dev/null +++ b/.github/actions/zip-name/zip-name.js @@ -0,0 +1,304 @@ +'use strict' + +/** + * Resolves the name of the zip a build will produce, without building it: the version the checkout + * holds, the pre-release suffix for a QA build, and the slug pup gives the zip. + * + * The name is known before the build so a caller can ask the S3 bucket whether that zip already + * exists and skip the build, which is how the Slack packaging bot works today. release-zip then + * builds the version this action resolved. + * + * runZipName validates the inputs, reads the .puprc, runs pup through the shared lib, and sets the + * outputs. The functions it is built from are pure, apart from the reads of the checkout and the + * commands run through `exec`, so a test can pass stand-ins and assert the calls. + */ + +const fs = require('node:fs') +const path = require('node:path') + +const { runOrFail } = require('../../lib/exec.js') +const { readVersionFiles } = require('../../lib/puprc.js') +const { setupPup, lastLine } = require('../../lib/pup.js') + +/** What was not done, to close every error message. */ +const WHAT = 'No zip name was resolved.' + +/** The pre-release labels a QA build is named with, in the order PHP's version_compare orders them. */ +const PRERELEASE_LABELS = ['dev', 'alpha', 'beta', 'rc'] + +/** A build sequence number: digits from 1, no leading zero. */ +const SEQUENCE_PATTERN = /^[1-9]\d*$/ + +/** + * The two git commands pup's DevSuffix.php runs for `pup get-version --dev`: the committer + * timestamp of HEAD and its 8-character hash. The action runs the same two, so a `dev` name is the + * one pup would give the same commit. + */ +const DEV_SUFFIX_COMMANDS = [ + ['show', '-s', '--format=%ct', 'HEAD'], + ['rev-parse', '--short=8', 'HEAD'], +] + +/** + * Reads a yes/no input. + * + * The workflow declares these as a `choice` of yes/no rather than a boolean: the packaging bot + * sends `yes` and `no`, and a boolean input would make it send `true`. The action checks the + * value itself, so a dispatch by hand or by a tool gets the same refusal. + * + * @param {string} value The input value. + * @param {string} input The input's name, for the message. + * + * @throws {Error} When it is neither `yes` nor `no`. + * + * @returns {boolean} True for `yes`. + */ +function readYesNo(value, input) { + const trimmed = String(value ?? '') + .trim() + .toLowerCase() + + if (trimmed !== 'yes' && trimmed !== 'no') { + throw new Error(`${input} '${String(value ?? '').trim()}' is not yes or no. ${WHAT}`) + } + + return trimmed === 'yes' +} + +/** + * Reads the `prerelease` input: the label a QA build is named with. + * + * @param {string} value The input value. Empty means `dev`, what every QA build was named until now. + * + * @throws {Error} When it is not one of the labels. + * + * @returns {string} The label. + */ +function readPrerelease(value) { + const trimmed = String(value ?? '') + .trim() + .toLowerCase() + + if (trimmed === '') { + return 'dev' + } + + if (!PRERELEASE_LABELS.includes(trimmed)) { + const accepted = new Intl.ListFormat('en', { type: 'disjunction' }).format(PRERELEASE_LABELS) + + throw new Error(`prerelease '${String(value ?? '').trim()}' is not ${accepted}. ${WHAT}`) + } + + return trimmed +} + +/** + * Reads the `sequence` input: the number of a build numbered by hand, `rc-2`. + * + * @param {string} value The input value. Empty leaves the build to the commit's timestamp and hash. + * + * @throws {Error} When it is not digits from 1 without a leading zero. + * + * @returns {string|null} The number as given, or null for an empty value. + */ +function readSequence(value) { + const trimmed = String(value ?? '').trim() + + if (trimmed === '') { + return null + } + + if (!SEQUENCE_PATTERN.test(trimmed)) { + throw new Error( + `sequence '${trimmed}' is not a build number: pass digits from 1, such as 2, with no leading zero, ` + + `or leave it empty for the commit's timestamp and hash. ${WHAT}` + ) + } + + return trimmed +} + +/** + * The suffix a QA build's version carries, hyphens throughout as pup writes them. + * + * `dev` with no sequence is `-dev--`, the string DevSuffix.php builds, so a default + * build is named as before. The other labels take its place; a sequence replaces the timestamp and + * the hash. In PHP's version_compare, `-`, `.` and `_` are the same separator and + * `dev < alpha < beta < RC < final`, so every suffixed version sorts below the release it precedes. + * + * @param {string} label The pre-release label. + * @param {string|null} sequence The build number, or null. + * @param {string} timestamp The committer timestamp of HEAD. + * @param {string} hash The 8-character hash of HEAD. + * + * @returns {string} The suffix, hyphen first. + */ +function buildSuffix(label, sequence, timestamp, hash) { + return sequence === null ? `-${label}-${timestamp}-${hash}` : `-${label}-${sequence}` +} + +/** + * Reads the version pup printed: its last line, which has to be a version. + * + * `pup get-version` prints `unknown` when the regex of the first version file matches nothing, and + * exits 0; the action fails on it, since a zip named `give.unknown.zip` helps nobody. + * + * @param {string} output What pup printed. + * + * @throws {Error} When the line is empty or `unknown`. + * + * @returns {string} The version. + */ +function readPrintedVersion(output) { + const version = lastLine(output) + + if (version === '' || version === 'unknown') { + throw new Error( + `pup get-version read no version from the first file .puprc lists under paths.versions` + + `${version === '' ? '' : ` (it printed '${version}')`}. Check the file and its regex. ${WHAT}` + ) + } + + return version +} + +/** + * Reads the committer timestamp and the short hash of HEAD, with the commands DevSuffix.php runs. + * + * @param {object} exec The @actions/exec toolkit. + * @param {string} cwd The checkout. + * + * @throws {Error} When git fails: a checkout without a commit, or no checkout at all. + * + * @returns {Promise<[string, string]>} The timestamp and the hash. + */ +async function readCommitStamp(exec, cwd) { + const printed = [] + for (const args of DEV_SUFFIX_COMMANDS) { + printed.push((await runOrFail(exec, 'git', args, { cwd, silent: true }, `git ${args[0]}`)).trim()) + } + + return [printed[0], printed[1]] +} + +/** + * 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 + } +} + +/** + * Validates the inputs, reads the version the checkout holds, builds the suffix and asks pup for + * the zip name. + * + * Every input check and the .puprc read run before pup is downloaded, so a wrong `production`, + * `prerelease` or `sequence`, or a repository without a usable .puprc, fails with nothing + * downloaded and nothing run. `expected-version` is checked once the version is read, before the + * suffix is built. + * + * `production` is the master switch: with `yes`, `prerelease` and `sequence` are ignored with a + * warning, never refused, so a form filled in by habit still produces the clean zip. + * + * @param {object} options + * @param {object} options.core The @actions/core toolkit, for the log and the outputs. + * @param {object} options.exec The @actions/exec toolkit. + * @param {object} options.env The environment the action manifest put its inputs in. + * @param {string} options.cwd The checkout, the working directory by default. + * @param {Function} options.fetchImpl What `fetch` to use for `latest`; a test passes a stand-in. + * + * @throws {Error} When an input is wrong, .puprc is unusable, the pup release cannot be run, pup + * fails, the version is missing or not the expected one, or pup names the zip + * unexpectedly. + * + * @returns {Promise} Resolves once the outputs are set. + */ +async function runZipName({ core, exec, env, cwd = process.cwd(), fetchImpl = globalThis.fetch }) { + const production = readYesNo(env.INPUT_PRODUCTION, 'production') + const prerelease = readPrerelease(env.INPUT_PRERELEASE) + const sequence = readSequence(env.INPUT_SEQUENCE) + const expected = String(env.INPUT_EXPECTED_VERSION ?? '').trim() + + readVersionFiles(readCheckoutFile(cwd, '.puprc'), WHAT) + + if (production && (prerelease !== 'dev' || sequence !== null)) { + const ignored = [ + prerelease !== 'dev' ? `prerelease '${prerelease}'` : null, + sequence !== null ? `sequence '${sequence}'` : null, + ] + .filter(Boolean) + .join(' and ') + + core.warning( + `production is yes, so ${ignored} ${ignored.includes(' and ') ? 'are' : 'is'} ignored: a production ` + + 'build is the clean ..zip. Set production to no for a pre-release build.' + ) + } + + const { pup } = await setupPup({ exec, env, fetchImpl, what: WHAT }) + + const baseVersion = readPrintedVersion( + await pup(['get-version'], { cwd, silent: true, noticesToStderr: true }) + ) + + if (expected !== '' && expected !== baseVersion) { + // core.info prints a plain line: the marker has to open an output line to count, where the + // release dashboard looks for it. + core.info(`VERSION_MISMATCH: expected ${expected}, but the version files hold ${baseVersion}.`) + throw new Error( + `The version files hold ${baseVersion}, not the expected ${expected}. The ref built is not the ` + + `one prepared for ${expected}, or the preparation did not run. ${WHAT}` + ) + } + + let suffix = '' + if (!production) { + // A numbered build needs nothing from the commit. + const [timestamp, hash] = sequence === null ? await readCommitStamp(exec, cwd) : [] + suffix = buildSuffix(prerelease, sequence, timestamp, hash) + } + + const version = `${baseVersion}${suffix}` + const zipName = lastLine(await pup(['zip-name', version], { cwd, silent: true, noticesToStderr: true })) + + // pup appends `.` to the slug from zip_name or composer.json. Anything else means the + // name is not what the S3 check and the upload will look for, so it is refused here. + if (zipName === '' || !zipName.endsWith(`.${version}`)) { + throw new Error( + `pup zip-name printed '${zipName}', not a name ending in .${version}. Check zip_name in .puprc, or ` + + `the name in composer.json. ${WHAT}` + ) + } + + core.info(`Version ${version}, zip ${zipName}.zip`) + core.setOutput('base-version', baseVersion) + core.setOutput('version', version) + core.setOutput('zip-name', zipName) + core.setOutput('prerelease', production ? '' : prerelease) +} + +module.exports = { + runZipName, + readYesNo, + readPrerelease, + readSequence, + readPrintedVersion, + buildSuffix, + PRERELEASE_LABELS, + DEV_SUFFIX_COMMANDS, +} diff --git a/tests/actions/zip-name/zip-name.test.js b/tests/actions/zip-name/zip-name.test.js new file mode 100644 index 0000000..b187625 --- /dev/null +++ b/tests/actions/zip-name/zip-name.test.js @@ -0,0 +1,492 @@ +'use strict' + +const { describe, it } = require('node:test') +const assert = require('node:assert') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { execFileSync } = require('node:child_process') + +const { + runZipName, + readYesNo, + readPrerelease, + readSequence, + readPrintedVersion, + buildSuffix, + PRERELEASE_LABELS, + DEV_SUFFIX_COMMANDS, +} = require('../../../.github/actions/zip-name/zip-name.js') +const { coreWithOutputs, recordingExec, recordedRealExec } = require('../../support/actions.js') + +/** A .puprc that names the zip and lists one version file. */ +const PUPRC = { + zip_name: 'give', + paths: { versions: [{ file: 'give.php', regex: '(Version: )(.+)' }] }, +} + +/** The phar the pinned release downloads to. */ +const PHAR = '/runner/temp/pup-2.0.0.phar' + +/** What the commit of the checkout looks like to git, as the rules answer it. */ +const TIMESTAMP = '1792689067' +const HASH = 'fbcf8681' + +/** + * 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(), 'zip-name-')) + + 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 step, over the inputs of a default QA build. + * + * @param {object} overrides Inputs to change. + * + * @returns {object} The environment. + */ +function nameEnv(overrides = {}) { + return { + INPUT_PRODUCTION: 'no', + INPUT_PRERELEASE: 'dev', + INPUT_SEQUENCE: '', + INPUT_EXPECTED_VERSION: '', + INPUT_PUP_VERSION: '2.0.0', + INPUT_TOKEN: '', + RUNNER_TEMP: '/runner/temp', + ...overrides, + } +} + +/** + * Rules under which every command the action runs succeeds: the checkout holds 4.19.0.1, and pup + * names the zip `give.` as it does from zip_name. + * + * @param {object[]} before Rules that take precedence, to make one command answer differently. + * + * @returns {object[]} Rules for recordingExec. + */ +function allSucceed(before = []) { + return [ + ...before, + { command: 'curl' }, + { command: 'php', args: /get-version$/, stdout: '4.19.0.1\n' }, + { command: 'php', args: / zip-name /, stdout: (args) => `give.${args.at(-1)}\n` }, + { command: 'git', args: /^show /, stdout: `${TIMESTAMP}\n` }, + { command: 'git', args: /^rev-parse /, stdout: `${HASH}\n` }, + ] +} + +/** + * 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(' ')) +} + +/** The calls up to and including the version read, the same on every build. */ +const UP_TO_VERSION = [ + '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`, +] + +/** The two git reads of the commit, as DevSuffix.php runs them. */ +const COMMIT_READS = ['git show -s --format=%ct HEAD', 'git rev-parse --short=8 HEAD'] + +describe('zip-name', () => { + describe('readYesNo', () => { + it('reads yes and no, whatever the case and the whitespace', () => { + assert.equal(readYesNo('yes', 'production'), true) + assert.equal(readYesNo(' No\n', 'production'), false) + }) + + it('refuses anything else, naming the input', () => { + for (const value of ['', 'true', 'false', '1', 'y', undefined]) { + assert.throws(() => readYesNo(value, 'production'), /production '.*' is not yes or no\. No zip name was resolved\./) + } + }) + }) + + describe('readPrerelease', () => { + it('reads the four labels, in any case', () => { + for (const label of PRERELEASE_LABELS) { + assert.equal(readPrerelease(label), label) + assert.equal(readPrerelease(label.toUpperCase()), label) + } + }) + + it('reads an empty value as dev, the name every QA build had', () => { + assert.equal(readPrerelease(''), 'dev') + assert.equal(readPrerelease(undefined), 'dev') + }) + + it('refuses any other label, listing the accepted ones', () => { + for (const value of ['nightly', 'rc1', 'rc-1', 'release', 'yes']) { + assert.throws(() => readPrerelease(value), /prerelease '.*' is not dev, alpha, beta, or rc\. No zip name was resolved\./) + } + }) + }) + + describe('readSequence', () => { + it('reads digits from 1', () => { + assert.equal(readSequence('1'), '1') + assert.equal(readSequence(' 12 '), '12') + }) + + it('reads an empty value as no sequence', () => { + assert.equal(readSequence(''), null) + assert.equal(readSequence(undefined), null) + }) + + it('refuses zero, a leading zero, a negative, a decimal and text', () => { + for (const value of ['0', '01', '-1', '1.5', 'abc', '2a', 'rc-2']) { + assert.throws(() => readSequence(value), /sequence '.*' is not a build number.*No zip name was resolved\./s, value) + } + }) + }) + + describe('buildSuffix', () => { + it('names a default build the way DevSuffix.php does: -dev--', () => { + // "-dev-{$timestamp}-{$hash}" in pup's src/Commands/Traits/DevSuffix.php. + assert.equal(buildSuffix('dev', null, TIMESTAMP, HASH), `-dev-${TIMESTAMP}-${HASH}`) + }) + + it('reads the commit with the two commands DevSuffix.php runs', () => { + // `git show -s --format=%ct HEAD` and `git rev-parse --short=8 HEAD`, in that file. + assert.deepEqual(DEV_SUFFIX_COMMANDS, [ + ['show', '-s', '--format=%ct', 'HEAD'], + ['rev-parse', '--short=8', 'HEAD'], + ]) + }) + + it('puts another label in place of dev', () => { + assert.equal(buildSuffix('rc', null, TIMESTAMP, HASH), `-rc-${TIMESTAMP}-${HASH}`) + }) + + it('puts a sequence in place of the timestamp and the hash', () => { + assert.equal(buildSuffix('rc', '2', TIMESTAMP, HASH), '-rc-2') + assert.equal(buildSuffix('beta', '10', TIMESTAMP, HASH), '-beta-10') + }) + }) + + describe('readPrintedVersion', () => { + it('reads the version pup printed, ignoring anything before it', () => { + assert.equal(readPrintedVersion('4.19.0.1\n'), '4.19.0.1') + assert.equal(readPrintedVersion('PHP Deprecated: something\n4.19.0.1\n'), '4.19.0.1') + }) + + it('keeps a version that is not numeric: what the files hold is what the zip carries', () => { + assert.equal(readPrintedVersion('4.19.0-beta.1\n'), '4.19.0-beta.1') + }) + + it('refuses an empty answer and unknown, which pup prints when the regex matches nothing', () => { + assert.throws(() => readPrintedVersion(''), /read no version.*No zip name was resolved/s) + assert.throws(() => readPrintedVersion('unknown\n'), /read no version.*printed 'unknown'/s) + }) + }) + + describe('runZipName', () => { + it('names a default QA build as pup get-version --dev would, from the commit', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await runZipName({ core, exec, env: nameEnv(), cwd }) + + const version = `4.19.0.1-dev-${TIMESTAMP}-${HASH}` + assert.deepEqual(sequence(calls), [ + ...UP_TO_VERSION, + ...COMMIT_READS, + `php -d display_errors=stderr ${PHAR} zip-name ${version}`, + ]) + assert.ok( + calls.filter((call) => call.command !== 'curl').every((call) => call.options.cwd === cwd), + 'pup and git run in the checkout' + ) + assert.deepEqual(outputs(), { + 'base-version': '4.19.0.1', + version, + 'zip-name': `give.${version}`, + prerelease: 'dev', + }) + }) + + it('names an rc build with the label in place of dev', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await runZipName({ core, exec, env: nameEnv({ INPUT_PRERELEASE: 'rc' }), cwd }) + + assert.deepEqual(outputs(), { + 'base-version': '4.19.0.1', + version: `4.19.0.1-rc-${TIMESTAMP}-${HASH}`, + 'zip-name': `give.4.19.0.1-rc-${TIMESTAMP}-${HASH}`, + prerelease: 'rc', + }) + }) + + it('names a numbered build with the sequence, reading nothing from the commit', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await runZipName({ core, exec, env: nameEnv({ INPUT_PRERELEASE: 'beta', INPUT_SEQUENCE: '2' }), cwd }) + + assert.deepEqual(sequence(calls), [...UP_TO_VERSION, `php -d display_errors=stderr ${PHAR} zip-name 4.19.0.1-beta-2`]) + assert.deepEqual(outputs(), { + 'base-version': '4.19.0.1', + version: '4.19.0.1-beta-2', + 'zip-name': 'give.4.19.0.1-beta-2', + prerelease: 'beta', + }) + }) + + it('names a production build after the clean version, reading nothing from the commit', async (t) => { + const { core, outputs, logged } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await runZipName({ core, exec, env: nameEnv({ INPUT_PRODUCTION: 'yes' }), cwd }) + + assert.deepEqual(sequence(calls), [...UP_TO_VERSION, `php -d display_errors=stderr ${PHAR} zip-name 4.19.0.1`]) + assert.deepEqual(outputs(), { + 'base-version': '4.19.0.1', + version: '4.19.0.1', + 'zip-name': 'give.4.19.0.1', + prerelease: '', + }) + assert.doesNotMatch(logged(), /::warning::/, 'the default prerelease is no cause for a warning') + }) + + it('ignores prerelease and sequence on a production build, and says so', async (t) => { + const cases = [ + [{ INPUT_PRERELEASE: 'rc', INPUT_SEQUENCE: '3' }, /production is yes, so prerelease 'rc' and sequence '3' are ignored/], + [{ INPUT_PRERELEASE: 'rc' }, /production is yes, so prerelease 'rc' is ignored/], + [{ INPUT_SEQUENCE: '3' }, /production is yes, so sequence '3' is ignored/], + ] + + for (const [overrides, warning] of cases) { + const { core, outputs, logged } = await coreWithOutputs(t) + const { exec } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await runZipName({ core, exec, env: nameEnv({ INPUT_PRODUCTION: 'yes', ...overrides }), cwd }) + + assert.match(logged(), warning) + assert.deepEqual(outputs(), { + 'base-version': '4.19.0.1', + version: '4.19.0.1', + 'zip-name': 'give.4.19.0.1', + prerelease: '', + }) + } + }) + + it('stops before downloading or running anything when production, prerelease or sequence is wrong', async (t) => { + const cases = [ + [{ INPUT_PRODUCTION: 'true' }, /production 'true' is not yes or no/], + [{ INPUT_PRODUCTION: '' }, /production '' is not yes or no/], + [{ INPUT_PRERELEASE: 'nightly' }, /prerelease 'nightly' is not dev, alpha, beta, or rc/], + [{ INPUT_SEQUENCE: 'abc' }, /sequence 'abc' is not a build number/], + [{ INPUT_SEQUENCE: '0' }, /sequence '0' is not a build number/], + [{ INPUT_SEQUENCE: '02' }, /sequence '02' is not a build number/], + [{ INPUT_PUP_VERSION: '2.0' }, /pup-version '2\.0' is not a pup release number/], + ] + + for (const [overrides, message] of cases) { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await assert.rejects(runZipName({ core, exec, env: nameEnv(overrides), cwd }), message) + assert.deepEqual(calls, [], `nothing ran for ${JSON.stringify(overrides)}`) + assert.deepEqual(outputs(), {}) + } + }) + + it('stops before downloading or running anything when the repository has no usable .puprc', async (t) => { + const cases = [ + [{}, /No \.puprc.*No zip name was resolved/s], + [{ '.puprc': '{ "paths": ' }, /\.puprc is not valid JSON/], + [{ '.puprc': {} }, /no paths\.versions/], + [{ '.puprc': { paths: { versions: [] } } }, /no paths\.versions/], + ] + + for (const [files, message] of cases) { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith(files) + + await assert.rejects(runZipName({ core, exec, env: nameEnv(), cwd }), message) + assert.deepEqual(calls, [], `nothing ran for ${JSON.stringify(files)}`) + assert.deepEqual(outputs(), {}) + } + }) + + it('accepts the version expected-version names', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await runZipName({ core, exec, env: nameEnv({ INPUT_EXPECTED_VERSION: ' 4.19.0.1 ' }), cwd }) + + assert.equal(outputs()['base-version'], '4.19.0.1') + }) + + it('prints VERSION_MISMATCH on its own line and fails when the files hold another version', async (t) => { + const { core, outputs, logged } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await assert.rejects( + runZipName({ core, exec, env: nameEnv({ INPUT_EXPECTED_VERSION: '4.19.0.2' }), cwd }), + /The version files hold 4\.19\.0\.1, not the expected 4\.19\.0\.2.*No zip name was resolved/s + ) + + assert.match(logged(), /^VERSION_MISMATCH: expected 4\.19\.0\.2, but the version files hold 4\.19\.0\.1\.$/m) + assert.deepEqual(sequence(calls), UP_TO_VERSION, 'nothing runs after the version read') + assert.deepEqual(outputs(), {}) + }) + + it('fails when pup reads no version, before naming anything', async (t) => { + for (const printed of ['unknown\n', '']) { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed([{ command: 'php', args: /get-version$/, stdout: printed }])) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await assert.rejects(runZipName({ core, exec, env: nameEnv(), cwd }), /pup get-version read no version/) + assert.deepEqual(sequence(calls), UP_TO_VERSION) + assert.deepEqual(outputs(), {}) + } + }) + + it('fails with pup output when pup cannot read the version files', async (t) => { + // pup refuses to load a .puprc whose version regex finds nothing, so get-version exits 1. + const { core, outputs } = await coreWithOutputs(t) + const { exec } = recordingExec( + allSucceed([ + { + command: 'php', + args: /get-version$/, + exitCode: 1, + stdout: 'ERROR: Could not find version in file give.php using regex "/(Version: )(.+)/"', + }, + ]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await assert.rejects( + runZipName({ core, exec, env: nameEnv(), cwd }), + /pup get-version failed.*Could not find version in file give\.php/s + ) + assert.deepEqual(outputs(), {}) + }) + + it('fails when pup names the zip without the version, rather than hand on a name the upload would miss', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec } = recordingExec(allSucceed([{ command: 'php', args: / zip-name /, stdout: 'give\n' }])) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await assert.rejects( + runZipName({ core, exec, env: nameEnv({ INPUT_PRODUCTION: 'yes' }), cwd }), + /pup zip-name printed 'give', not a name ending in \.4\.19\.0\.1.*zip_name in \.puprc/s + ) + assert.deepEqual(outputs(), {}) + }) + + it('fails with pup output when pup zip-name fails', async (t) => { + // Without zip_name in .puprc and without a composer.json, pup throws a ConfigException. + const { core } = await coreWithOutputs(t) + const { exec } = recordingExec( + allSucceed([{ command: 'php', args: / zip-name /, exitCode: 1, stderr: 'Could not find a "zip_name" in .puprc' }]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await assert.rejects(runZipName({ core, exec, env: nameEnv(), cwd }), /pup zip-name failed.*zip_name/s) + }) + + it('fails when the release exists but has no phar, naming the pin', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([ + { 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 }) + + await assert.rejects( + runZipName({ core, exec, env: nameEnv(), cwd }), + /pup 2\.0\.0 publishes no pup\.phar asset.*such as 2\.0\.0\. No zip name was resolved\./s + ) + assert.ok(!sequence(calls).some((call) => /fsSL|php |git /.test(call)), 'nothing downloaded, nothing run') + assert.deepEqual(outputs(), {}) + }) + + it('resolves latest through the API with the token, then downloads that release', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC }) + const fetched = [] + const fetchImpl = async (url, options) => { + fetched.push([url, options]) + return { ok: true, json: async () => ({ tag_name: '2.0.0' }) } + } + + await runZipName({ core, exec, env: nameEnv({ 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('download/2.0.0/pup.phar'))) + assert.equal(outputs().version, `4.19.0.1-dev-${TIMESTAMP}-${HASH}`) + }) + + it('gives a real commit the suffix pup get-version --dev gives it', async (t) => { + // The two git reads run for real, against a repository made here; pup stays a stand-in. What + // DevSuffix.php would build from the same commit is computed alongside and has to match. + const { core, outputs } = await coreWithOutputs(t) + const work = fs.mkdtempSync(path.join(os.tmpdir(), 'zip-name-git-')) + const git = (...args) => execFileSync('git', args, { cwd: work, encoding: 'utf8' }).trim() + git('init', '--quiet') + git('config', 'user.name', 'Test') + git('config', 'user.email', 'test@example.com') + fs.writeFileSync(path.join(work, '.puprc'), JSON.stringify(PUPRC)) + fs.writeFileSync(path.join(work, 'give.php'), 'Version: 4.19.0.1\n') + git('add', '-A') + git('commit', '--quiet', '-m', 'Initial') + + const real = await recordedRealExec() + const fake = recordingExec(allSucceed()) + const exec = { + getExecOutput: (command, args, options) => + (command === 'git' ? real.exec : fake.exec).getExecOutput(command, args, options), + } + + await runZipName({ core, exec, env: nameEnv(), cwd: work }) + + const devSuffix = `-dev-${git('show', '-s', '--format=%ct', 'HEAD')}-${git('rev-parse', '--short=8', 'HEAD')}` + assert.match(devSuffix, /^-dev-\d{10}-[0-9a-f]{8}$/) + assert.equal(outputs().version, `4.19.0.1${devSuffix}`) + assert.equal(outputs()['zip-name'], `give.4.19.0.1${devSuffix}`) + }) + }) +}) diff --git a/tests/support/actions.js b/tests/support/actions.js index caab724..f89413e 100644 --- a/tests/support/actions.js +++ b/tests/support/actions.js @@ -290,7 +290,8 @@ async function coreWithOutputs(t) { * 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. + * stands in for a file the command would have written. `stdout` may be a function of the arguments, + * for a command whose answer repeats one of them, as `pup zip-name ` does. * * 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. @@ -318,7 +319,11 @@ function recordingExec(rules = []) { } const result = rule - ? { exitCode: rule.exitCode ?? 0, stdout: rule.stdout ?? '', stderr: rule.stderr ?? '' } + ? { + exitCode: rule.exitCode ?? 0, + stdout: (typeof rule.stdout === 'function' ? rule.stdout(args) : rule.stdout) ?? '', + stderr: rule.stderr ?? '', + } : { exitCode: 127, stdout: '', stderr: `${command}: no test rule answers this call` } if (result.exitCode !== 0 && !options.ignoreReturnCode) { diff --git a/tests/support/actions.test.js b/tests/support/actions.test.js index 97e683f..665796f 100644 --- a/tests/support/actions.test.js +++ b/tests/support/actions.test.js @@ -107,6 +107,14 @@ describe('tests/support/actions', () => { assert.equal(result.exitCode, 127) }) + it('answers with stdout built from the arguments when the rule gives a function', async () => { + const { exec } = recordingExec([{ command: 'php', stdout: (args) => `give.${args.at(-1)}\n` }]) + + const result = await exec.getExecOutput('php', ['pup.phar', 'zip-name', '4.19.0-rc-2']) + + assert.equal(result.stdout, 'give.4.19.0-rc-2\n') + }) + it('runs a rule effect, so a test can stand in for what the command writes', async () => { const written = [] const { exec } = recordingExec([ From a249f411e1a21943776026409c63aafc2b7f12b4 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Wed, 4 Nov 2026 14:01:07 -0300 Subject: [PATCH 03/19] Add release-zip: pup build, check, i18n and package for the version zip-name resolved pup check runs strict on a production build and with --dev on a QA build, the mode pup gives each check's fail_method_dev. The zip pup wrote is checked to exist before its path is handed on. --- .github/actions/release-zip/README.md | 143 ++++++++ .github/actions/release-zip/action.yml | 79 +++++ .github/actions/release-zip/release-zip.js | 169 +++++++++ tests/actions/release-zip/release-zip.test.js | 325 ++++++++++++++++++ 4 files changed, 716 insertions(+) create mode 100644 .github/actions/release-zip/README.md create mode 100644 .github/actions/release-zip/action.yml create mode 100644 .github/actions/release-zip/release-zip.js create mode 100644 tests/actions/release-zip/release-zip.test.js diff --git a/.github/actions/release-zip/README.md b/.github/actions/release-zip/README.md new file mode 100644 index 0000000..b047dc0 --- /dev/null +++ b/.github/actions/release-zip/README.md @@ -0,0 +1,143 @@ +# release-zip + +Builds and packages a version of the plugin with pup, from the root of the checkout: + +```yaml +- uses: stellarwp/plugin-toolbox/.github/actions/zip-name@v1 + id: name + with: + production: ${{ inputs.production }} + prerelease: ${{ inputs.prerelease }} + +- uses: stellarwp/plugin-toolbox/.github/actions/release-zip@v1 + id: zip + with: + version: ${{ steps.name.outputs.version }} + production: ${{ inputs.production }} + +- uses: actions/upload-artifact@v7 + with: + name: ${{ steps.zip.outputs.zip-name }} + path: ${{ steps.zip.outputs.zip-path }} +``` + +The version is the one zip-name resolved, pre-release suffix included. Naming and building are two +actions so a caller can ask the S3 bucket for the name and skip the build; see +[zip-name](../zip-name/README.md). [`templates/workflows/zip.yml`](../../../templates/workflows/zip.yml) +is a complete workflow around the two, ready to copy into a plugin repository. + +## What it does + +Four pup commands run, in this order, from the root of the checkout: + +1. `pup build` runs the build commands `.puprc` lists under `build`: `composer install`, + `npm run build`, whatever the plugin needs before it ships. +2. `pup check` runs the checks `.puprc` lists under `checks`, the `TBD` check and the + version-conflict check among them. Strict on a production build, so a `TBD` left behind fails + the build; with `--dev` on a QA build, where each check's `fail_method_dev` applies, a warning by + default. Skipped with `check: no`. +3. `pup i18n` downloads the finished translations the `i18n` section of `.puprc` points at. Nothing + happens without the section. Skipped with `i18n: no`. It does not generate the POT file; a + plugin that ships one generates it in a step of its own, before this action. +4. `pup package ` writes `version` into every file `.puprc` lists under `paths.versions`, + syncs the files `.distignore` or `.distfiles` keep into `.pup-zip/`, zips them as + `.zip` in the root of the checkout, and restores the version files with git. The + checkout is left as it was; only the zip carries the suffix. + +A command that fails fails the step with pup's output in the log, and the commands after it do not +run. The action then asks `pup zip-name ` for the name pup gave the zip and checks that the +file is there, so the `zip-path` output always names a file that exists. + +The build commands run with the step's environment. A build that installs private composer packages +gets `COMPOSER_AUTH` from the caller's step: + +```yaml +- uses: stellarwp/plugin-toolbox/.github/actions/release-zip@v1 + with: + version: ${{ steps.name.outputs.version }} + env: + COMPOSER_AUTH: >- + ${{ secrets.COMPOSER_TOKEN != '' && format('{{"github-oauth": {{"github.com": "{0}"}}}}', secrets.COMPOSER_TOKEN) || '' }} +``` + +## What the repository needs + +A `.puprc` with the version files, the zip name and the build commands, and the PHP, Node and other +tools the build commands run, set up by the caller before this action. pup itself runs on the +runner's PHP; the template sets up the oldest PHP the plugin supports, so the vendor directory +`pup build` installs runs on it. + +```json +{ + "zip_name": "give", + "build": ["composer install --no-dev", "npm ci", "npm run build"], + "paths": { + "versions": [ + { "file": "give.php", "regex": "(Version: )(.+)" }, + { "file": "readme.txt", "regex": "(Stable tag: )(.+)" } + ] + }, + "checks": { + "tbd": { "dirs": ["src"] }, + "version-conflict": {} + } +} +``` + +The action checks the `.puprc` before downloading anything: a missing file, one that is not JSON, +or one with no `paths.versions` fails the step before pup runs, as zip-name does. + +## What it refuses + +Before anything is downloaded or run: + +- An empty `version`. The template passes zip-name's `version` output, which is never empty. +- `production`, `check` or `i18n` that is not `yes` or `no`. +- A missing or unusable `.puprc`. + +After the build, a zip that is not where pup said it would be: `pup package` exited 0 but +`.zip` is not in the root of the checkout. That points at `zip_name` in `.puprc`, or the +name in `composer.json`. + +## Inputs + +| Input | Required | Default | What it does | +|---|---|---|---| +| `version` | yes | | The version the zip carries, zip-name's `version` output | +| `production` | no | `no` | `yes` runs `pup check` strict; `no` runs it with `--dev` | +| `check` | no | `yes` | Whether `pup check` runs | +| `i18n` | no | `yes` | Whether `pup i18n` runs | +| `pup-version` | no | `latest` | The pup release to run: a number such as `2.0.0`, or `latest`. Pass the same value zip-name got. See [Which pup runs](#which-pup-runs) | +| `token` | no | `github.token` | Authenticates the `latest` lookup. A pinned `pup-version` never uses it | + +## Outputs + +| Output | Example | What it is | +|---|---|---| +| `zip-name` | `give.4.19.0.1-rc-1792689067-fbcf8681` | The zip name without `.zip`, as `pup zip-name` gives it | +| `zip-path` | `/home/runner/work/give/give/give.4.19.0.1-rc-1792689067-fbcf8681.zip` | The zip, as an absolute path | + +## 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`. The release has to publish a `pup.phar` asset, which +every release from 1.2.4 to 2.0.0 does; one without it fails the step and names a pin to use. How +pup is resolved, downloaded and run is shared with release-prep and zip-name, in +[`.github/lib/pup.js`](../../lib/pup.js). + +## How it runs + +One [actions/github-script](https://github.com/actions/github-script) step runs +[`release-zip.js`](./release-zip.js). 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 because +github-script's `require` resolves against the workspace of the repository being built, not this +action's directory. + +Inputs reach the script through `process.env`, never by being interpolated into it, so a version is +data and cannot become part of the program. The commands run with their arguments as a list, +without a shell. `pup build`, `check`, `i18n` and `package` print to the log; only `pup zip-name`, +whose output the action reads, runs silently. + +`tests/actions/release-zip/release-zip.test.js` covers the rules above against a stand-in for +`exec`. Run `npm install` once, then `node --test`. diff --git a/.github/actions/release-zip/action.yml b/.github/actions/release-zip/action.yml new file mode 100644 index 0000000..69d4c7a --- /dev/null +++ b/.github/actions/release-zip/action.yml @@ -0,0 +1,79 @@ +# Builds and packages a version of the plugin with pup: `pup build`, `pup check`, `pup i18n`, then +# `pup package `. The version is the one zip-name resolved, pre-release suffix included: pup +# writes it into the version files inside the zip and leaves the checkout as it was. The zip lands +# in the root of the checkout, where the caller uploads it as an artifact or syncs it to S3. +# +# The logic lives in release-zip.js, run by actions/github-script, so there is no dependency to +# install and nothing to build. pup is downloaded into the runner's temp directory and runs on the +# runner's PHP, which the caller sets up at the version the plugin's vendor directory needs. +name: Release zip +description: Builds, checks and packages a version of the plugin with pup + +inputs: + version: + description: >- + The version the zip carries, the `version` output of zip-name: the base version for a + production build, with the pre-release suffix for a QA build. pup writes it into the version + files inside the zip. Empty fails before anything runs. + required: true + production: + description: >- + `yes` runs `pup check` strict, so a `TBD` left behind or a version conflict fails the build; + `no` runs it with `--dev`, where the same findings are warnings. Anything else fails before + anything runs. + required: false + default: 'no' + check: + description: '`yes` runs `pup check`; `no` skips it. Anything else fails before anything runs.' + required: false + default: 'yes' + i18n: + description: '`yes` runs `pup i18n`, which downloads the finished translations; `no` skips it. Anything else fails before anything runs.' + required: false + default: 'yes' + pup-version: + description: >- + The pup release that builds and packages: 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; + a release without one fails the step and names a pin to use instead. Pass the same value + zip-name got, so the zip is named by the pup that read the version. + required: false + default: 'latest' + token: + description: >- + Authenticates the 'latest' lookup of pup-version against the GitHub API, so the runner's shared + quota is not spent. A pinned pup-version never uses it. + required: false + default: ${{ github.token }} + +outputs: + zip-name: + description: 'The zip name without `.zip`, as `pup zip-name` gives it for the version built.' + value: ${{ steps.zip.outputs.zip-name }} + zip-path: + description: 'The zip pup wrote, `.zip` in the root of the checkout, as an absolute path.' + value: ${{ steps.zip.outputs.zip-path }} + +runs: + using: 'composite' + steps: + - name: Build, check and package the version + id: zip + 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_VERSION: ${{ inputs.version }} + INPUT_PRODUCTION: ${{ inputs.production }} + INPUT_CHECK: ${{ inputs.check }} + INPUT_I18N: ${{ inputs.i18n }} + INPUT_PUP_VERSION: ${{ inputs.pup-version }} + INPUT_TOKEN: ${{ inputs.token }} + with: + script: | + const { runZip } = require(`${process.env.ACTION_PATH}/release-zip.js`) + await runZip({ core, exec, env: process.env }) diff --git a/.github/actions/release-zip/release-zip.js b/.github/actions/release-zip/release-zip.js new file mode 100644 index 0000000..20533d7 --- /dev/null +++ b/.github/actions/release-zip/release-zip.js @@ -0,0 +1,169 @@ +'use strict' + +/** + * Builds and packages a version of the plugin with pup: `pup build`, `pup check`, `pup i18n`, then + * `pup package `, from the root of the checkout. The version is the one zip-name resolved, + * suffix included: pup writes it into the version files inside the zip and leaves the checkout as + * it was. + * + * runZip validates the inputs, reads the .puprc, runs pup through the shared lib and sets the + * outputs. The functions it is built from are pure, apart from the reads of the checkout and the + * commands run through `exec`, so a test can pass stand-ins and assert the calls. + */ + +const fs = require('node:fs') +const path = require('node:path') + +const { readVersionFiles } = require('../../lib/puprc.js') +const { setupPup, lastLine } = require('../../lib/pup.js') + +/** What was not done, to close every error message. */ +const WHAT = 'No zip was built.' + +/** + * Reads the version to build, refusing an empty one. + * + * @param {string} version The `version` input. + * + * @throws {Error} When it is empty. + * + * @returns {string} The version, without surrounding whitespace. + */ +function readVersion(version) { + const trimmed = String(version ?? '').trim() + + if (trimmed === '') { + throw new Error( + `No version was given: pass the version the zip carries, the \`version\` output of zip-name. ${WHAT}` + ) + } + + return trimmed +} + +/** + * Reads a yes/no input. + * + * @param {string} value The input value. + * @param {string} input The input's name, for the message. + * + * @throws {Error} When it is neither `yes` nor `no`. + * + * @returns {boolean} True for `yes`. + */ +function readYesNo(value, input) { + const trimmed = String(value ?? '') + .trim() + .toLowerCase() + + if (trimmed !== 'yes' && trimmed !== 'no') { + throw new Error(`${input} '${String(value ?? '').trim()}' is not yes or no. ${WHAT}`) + } + + return trimmed === 'yes' +} + +/** + * The pup commands a build runs, in order. + * + * `pup check` runs strict on a production build, so a `TBD` left behind or a version conflict fails + * it, and with `--dev` on a QA build, where the same findings are warnings: the mode pup gives each + * check's `fail_method_dev`. `pup i18n` downloads the finished translations; `pup package` writes + * the version into the files `.puprc` lists, syncs the files `.distignore` or `.distfiles` keep into + * `.pup-zip`, zips them and restores the files. + * + * @param {object} options + * @param {string} options.version The version to package. + * @param {boolean} options.production Whether `pup check` runs strict. + * @param {boolean} options.check Whether `pup check` runs at all. + * @param {boolean} options.i18n Whether `pup i18n` runs. + * + * @returns {string[][]} The arguments of each pup command. + */ +function pupCommands({ version, production, check, i18n }) { + const commands = [['build']] + + if (check) { + commands.push(production ? ['check'] : ['check', '--dev']) + } + if (i18n) { + commands.push(['i18n']) + } + commands.push(['package', version]) + + return commands +} + +/** + * 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 + } +} + +/** + * Validates the inputs, then builds and packages the version with pup. + * + * Every input check and the .puprc read run before pup is downloaded. A pup command that fails + * fails the step with pup's output, and the commands after it do not run. `COMPOSER_AUTH`, when the + * caller's step sets it, reaches the build commands through the environment, as every variable + * does. + * + * @param {object} options + * @param {object} options.core The @actions/core toolkit, for the log and the outputs. + * @param {object} options.exec The @actions/exec toolkit. + * @param {object} options.env The environment the action manifest put its inputs in. + * @param {string} options.cwd The checkout, the working directory by default. + * @param {Function} options.fetchImpl What `fetch` to use for `latest`; a test passes a stand-in. + * + * @throws {Error} When an input is wrong, .puprc is unusable, the pup release cannot be run, a pup + * command fails, or the zip pup reported is not in the checkout. + * + * @returns {Promise} Resolves once the zip exists and the outputs are set. + */ +async function runZip({ core, exec, env, cwd = process.cwd(), fetchImpl = globalThis.fetch }) { + const version = readVersion(env.INPUT_VERSION) + const production = readYesNo(env.INPUT_PRODUCTION, 'production') + const check = readYesNo(env.INPUT_CHECK, 'check') + const i18n = readYesNo(env.INPUT_I18N, 'i18n') + + readVersionFiles(readCheckoutFile(cwd, '.puprc'), WHAT) + + const { pup } = await setupPup({ exec, env, fetchImpl, what: WHAT }) + + for (const args of pupCommands({ version, production, check, i18n })) { + await pup(args, { cwd }) + } + + // The name pup gave the zip, from the same rules package used, rather than a guess from here. + const zipName = lastLine(await pup(['zip-name', version], { cwd, silent: true, noticesToStderr: true })) + const zipPath = path.join(cwd, `${zipName}.zip`) + + if (zipName === '' || !fs.existsSync(zipPath)) { + throw new Error( + `pup package finished but ${zipName === '' ? 'pup zip-name printed no name' : `${zipName}.zip is not in the checkout`}. ` + + `Check zip_name in .puprc, or the name in composer.json. ${WHAT}` + ) + } + + core.info(`Built ${zipName}.zip`) + core.setOutput('zip-name', zipName) + core.setOutput('zip-path', zipPath) +} + +module.exports = { runZip, readVersion, readYesNo, pupCommands } diff --git a/tests/actions/release-zip/release-zip.test.js b/tests/actions/release-zip/release-zip.test.js new file mode 100644 index 0000000..c73217d --- /dev/null +++ b/tests/actions/release-zip/release-zip.test.js @@ -0,0 +1,325 @@ +'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 { runZip, readVersion, readYesNo, pupCommands } = require('../../../.github/actions/release-zip/release-zip.js') +const { coreWithOutputs, recordingExec } = require('../../support/actions.js') + +/** A .puprc that names the zip and lists one version file. */ +const PUPRC = { + zip_name: 'give', + paths: { versions: [{ file: 'give.php', regex: '(Version: )(.+)' }] }, +} + +/** The phar the pinned release downloads to. */ +const PHAR = '/runner/temp/pup-2.0.0.phar' + +/** The version zip-name resolved for an rc build. */ +const VERSION = '4.19.0.1-rc-1792689067-fbcf8681' + +/** + * 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-zip-')) + + 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 step, over the inputs of a QA build with every step on. + * + * @param {object} overrides Inputs to change. + * + * @returns {object} The environment. + */ +function zipEnv(overrides = {}) { + return { + INPUT_VERSION: VERSION, + INPUT_PRODUCTION: 'no', + INPUT_CHECK: 'yes', + INPUT_I18N: 'yes', + INPUT_PUP_VERSION: '2.0.0', + INPUT_TOKEN: '', + RUNNER_TEMP: '/runner/temp', + ...overrides, + } +} + +/** + * Rules under which every command succeeds: `pup package` writes the zip into the checkout, as + * pup does, and `pup zip-name` names it from zip_name. + * + * @param {object[]} before Rules that take precedence, to make one command answer differently. + * + * @returns {object[]} Rules for recordingExec. + */ +function allSucceed(before = []) { + return [ + ...before, + { command: 'curl' }, + { + command: 'php', + args: / package /, + effect: (args, options) => fs.writeFileSync(path.join(options.cwd, `give.${args.at(-1)}.zip`), 'PK'), + }, + { command: 'php', args: / zip-name /, stdout: (args) => `give.${args.at(-1)}\n` }, + { 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(' ')) +} + +/** The download, the same on every build. */ +const DOWNLOAD = [ + '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`, +] + +describe('release-zip', () => { + describe('readVersion', () => { + it('reads the version without surrounding whitespace', () => { + assert.equal(readVersion(` ${VERSION}\n`), VERSION) + }) + + it('refuses an empty version, pointing at the zip-name output', () => { + for (const empty of ['', ' ', undefined]) { + assert.throws(() => readVersion(empty), /No version was given.*output of zip-name.*No zip was built/s) + } + }) + }) + + describe('readYesNo', () => { + it('reads yes and no, whatever the case and the whitespace', () => { + assert.equal(readYesNo('YES', 'check'), true) + assert.equal(readYesNo(' no ', 'check'), false) + }) + + it('refuses anything else, naming the input', () => { + for (const value of ['', 'true', 'false', 'on', undefined]) { + assert.throws(() => readYesNo(value, 'i18n'), /i18n '.*' is not yes or no\. No zip was built\./) + } + }) + }) + + describe('pupCommands', () => { + it('builds, checks with --dev, fetches the translations and packages a QA build', () => { + assert.deepEqual(pupCommands({ version: VERSION, production: false, check: true, i18n: true }), [ + ['build'], + ['check', '--dev'], + ['i18n'], + ['package', VERSION], + ]) + }) + + it('checks strict on a production build', () => { + assert.deepEqual(pupCommands({ version: '4.19.0.1', production: true, check: true, i18n: true }), [ + ['build'], + ['check'], + ['i18n'], + ['package', '4.19.0.1'], + ]) + }) + + it('leaves out the check and the translations when asked', () => { + assert.deepEqual(pupCommands({ version: VERSION, production: false, check: false, i18n: false }), [ + ['build'], + ['package', VERSION], + ]) + }) + }) + + describe('runZip', () => { + it('downloads pup, then builds, checks, fetches the translations and packages, in the checkout', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await runZip({ core, exec, env: zipEnv(), cwd }) + + assert.deepEqual(sequence(calls), [ + ...DOWNLOAD, + `php ${PHAR} build`, + `php ${PHAR} check --dev`, + `php ${PHAR} i18n`, + `php ${PHAR} package ${VERSION}`, + `php -d display_errors=stderr ${PHAR} zip-name ${VERSION}`, + ]) + assert.ok( + calls.filter((call) => call.command === 'php').every((call) => call.options.cwd === cwd), + 'pup runs in the checkout' + ) + assert.deepEqual(outputs(), { + 'zip-name': `give.${VERSION}`, + 'zip-path': path.join(cwd, `give.${VERSION}.zip`), + }) + }) + + it('shows the build in the log and reads only the zip name silently', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await runZip({ core, exec, env: zipEnv(), cwd }) + + const php = calls.filter((call) => call.command === 'php') + assert.ok(php.slice(0, -1).every((call) => !call.options.silent), 'pup build, check, i18n and package print to the log') + assert.equal(php.at(-1).options.silent, true) + }) + + it('checks strict on a production build', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await runZip({ core, exec, env: zipEnv({ INPUT_VERSION: '4.19.0.1', INPUT_PRODUCTION: 'yes' }), cwd }) + + assert.ok(sequence(calls).includes(`php ${PHAR} check`)) + assert.ok(!sequence(calls).some((call) => call.endsWith('check --dev'))) + }) + + it('skips the check and the translations when asked', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await runZip({ core, exec, env: zipEnv({ INPUT_CHECK: 'no', INPUT_I18N: 'no' }), cwd }) + + assert.deepEqual( + sequence(calls).filter((call) => call.startsWith('php')), + [`php ${PHAR} build`, `php ${PHAR} package ${VERSION}`, `php -d display_errors=stderr ${PHAR} zip-name ${VERSION}`] + ) + }) + + it('stops before downloading or running anything when an input is wrong', async (t) => { + const cases = [ + [{ INPUT_VERSION: '' }, /No version was given/], + [{ INPUT_PRODUCTION: 'true' }, /production 'true' is not yes or no/], + [{ INPUT_CHECK: '' }, /check '' is not yes or no/], + [{ INPUT_I18N: 'on' }, /i18n 'on' is not yes or no/], + [{ INPUT_PUP_VERSION: 'newest' }, /pup-version 'newest' is not a pup release number/], + ] + + for (const [overrides, message] of cases) { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await assert.rejects(runZip({ core, exec, env: zipEnv(overrides), cwd }), message) + assert.deepEqual(calls, [], `nothing ran for ${JSON.stringify(overrides)}`) + assert.deepEqual(outputs(), {}) + } + }) + + it('stops before downloading or running anything when the repository has no usable .puprc', async (t) => { + const cases = [ + [{}, /No \.puprc.*No zip was built/s], + [{ '.puprc': '{ "paths": ' }, /\.puprc is not valid JSON/], + [{ '.puprc': {} }, /no paths\.versions/], + ] + + for (const [files, message] of cases) { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith(files) + + await assert.rejects(runZip({ core, exec, env: zipEnv(), cwd }), message) + assert.deepEqual(calls, [], `nothing ran for ${JSON.stringify(files)}`) + assert.deepEqual(outputs(), {}) + } + }) + + it('fails with pup output when a pup command fails, and runs nothing after it', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([{ command: 'php', args: / check /, exitCode: 1, stdout: '[tbd] TBD found in src/Thing.php' }]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await assert.rejects(runZip({ core, exec, env: zipEnv(), cwd }), /pup check failed.*TBD found in src\/Thing\.php/s) + + assert.ok(!sequence(calls).some((call) => / (i18n|package|zip-name)/.test(call)), 'nothing runs after the failure') + assert.deepEqual(outputs(), {}) + }) + + it('fails with pup output when pup build fails', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec } = recordingExec( + allSucceed([{ command: 'php', args: / build$/, exitCode: 1, stdout: '[FAIL] Build step failed: npm run build' }]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await assert.rejects(runZip({ core, exec, env: zipEnv(), cwd }), /pup build failed.*npm run build/s) + }) + + it('fails when pup package finishes but the zip is not in the checkout', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec } = recordingExec(allSucceed([{ command: 'php', args: / package / }])) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await assert.rejects( + runZip({ core, exec, env: zipEnv(), cwd }), + /pup package finished but give\.4\.19\.0\.1-rc-1792689067-fbcf8681\.zip is not in the checkout/ + ) + assert.deepEqual(outputs(), {}) + }) + + it('fails when the release exists but has no phar, naming the pin', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([ + { 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 }) + + await assert.rejects( + runZip({ core, exec, env: zipEnv(), cwd }), + /pup 2\.0\.0 publishes no pup\.phar asset.*such as 2\.0\.0\. No zip was built\./s + ) + assert.ok(!sequence(calls).some((call) => /fsSL|php /.test(call)), 'nothing downloaded, nothing run') + assert.deepEqual(outputs(), {}) + }) + + it('resolves latest through the API with the token, then downloads that release', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC }) + const fetched = [] + const fetchImpl = async (url, options) => { + fetched.push([url, options]) + return { ok: true, json: async () => ({ tag_name: '2.0.0' }) } + } + + await runZip({ core, exec, env: zipEnv({ 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('download/2.0.0/pup.phar'))) + }) + }) +}) From 07590935e5fdad3af90dd122bdfd7c252af4fa57 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Wed, 4 Nov 2026 14:01:07 -0300 Subject: [PATCH 04/19] A caller template, zip.yml: the QA and production zip the bot and the dashboard dispatch The body of stellarwp/github-actions' zip.yml as a file to copy: the toolchain, zip-name, the check of the bucket for a default QA build, the plugin's own steps, release-zip, the upload, the Slack notice and the artifact. Inputs reach the shell through env, never interpolated into run:. --- templates/workflows/zip.yml | 225 ++++++++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 templates/workflows/zip.yml diff --git a/templates/workflows/zip.yml b/templates/workflows/zip.yml new file mode 100644 index 0000000..0f76ab8 --- /dev/null +++ b/templates/workflows/zip.yml @@ -0,0 +1,225 @@ +name: Generate Zip + +# Builds the plugin zip with pup and names it after the version the checkout holds: the clean +# `..zip` for a production build, `.-dev--.zip` for a +# QA build, or `-rc-…` / `-beta-…` / `-alpha-…` when asked for a pre-release. The version inside the +# zipped files is the same string; the checkout is left as it was. The zip goes to the S3 bucket and +# to Slack when the repository has the secrets for them, and is attached to the run as an artifact. +# +# A file to copy into .github/workflows/ of a plugin repository, not a workflow to call. Keep the +# name zip.yml: the packaging bot in stellarwp/jenkins-scripts looks for it first. The repository +# needs a .puprc with paths.versions and a zip_name (or a composer.json with a name); see the +# zip-name action's README. +# +# The inputs are the interface the bot and the release dashboard dispatch: `ref` and `production`. +# `prerelease` and `sequence` name a QA build; `expected_version` refuses a build of the wrong ref. +# +# Run it locally: `pup build && pup check --dev && pup i18n && pup package "$(pup get-version --dev)"`. +# Start it by hand with `gh workflow run zip.yml -f ref=release/M26.jigglypuff -f prerelease=rc`. + +on: + workflow_dispatch: + inputs: + ref: + description: 'Branch, tag or commit to build' + required: true + type: string + default: develop + # A choice of yes/no rather than a boolean: the packaging bot only sends yes/no for choice + # inputs. Declared as a boolean it would send `true`, which the actions refuse. + production: + description: 'Production build? yes names the zip after the clean version; no adds a pre-release suffix' + required: false + type: choice + default: 'no' + options: + - 'yes' + - 'no' + prerelease: + description: 'Label of a QA build (production: no). dev names it as before, -dev--' + required: false + type: choice + default: dev + options: + - dev + - alpha + - beta + - rc + sequence: + description: 'Number of a QA build numbered by hand, e.g. 2 for -rc-2. Empty uses the commit timestamp and hash' + required: false + type: string + default: '' + expected_version: + description: 'When set, the version the files have to hold; a different one fails with VERSION_MISMATCH' + required: false + type: string + default: '' + slack_channel: + description: 'Slack channel ID to post to' + required: false + type: string + default: '' + slack_thread: + description: 'Slack thread to post to' + required: false + type: string + default: '' + +permissions: + contents: read + +jobs: + zip: + # pup runs on the PHP set up below, which also serves the plugin's own build. Give runs on + # Blacksmith: change the label here. + runs-on: ubuntu-24.04 + env: + # The secrets context is not available in a step's `if`, so whether the bucket and Slack are + # configured is decided here. Without the secrets the zip is still built and attached as an + # artifact; only the upload and the notice are skipped. + S3_CONFIGURED: ${{ secrets.ZIP_S3_BUCKET != '' }} + SLACK_CONFIGURED: ${{ secrets.SLACK_PACKAGING_SECRET != '' }} + # For a build that installs packages from GitHub Packages. Both are empty without the secret. + NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} + BUN_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + # Nothing here pushes, so the checkout keeps no credentials in .git/config. + persist-credentials: false + + # Node only when the repository pins one, and the npm cache only when there is a lockfile for + # it to key on: setup-node fails on `cache: npm` without one, which a bun-only repository has. + - name: Check for .nvmrc and an npm lockfile + id: node + run: | + if [ -f .nvmrc ]; then echo "nvmrc=true" >> "$GITHUB_OUTPUT"; else echo "nvmrc=false" >> "$GITHUB_OUTPUT"; fi + if [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ] || [ -f yarn.lock ]; then + echo "cache=npm" >> "$GITHUB_OUTPUT" + else + echo "cache=" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/setup-node@v6 + if: steps.node.outputs.nvmrc == 'true' + with: + node-version-file: .nvmrc + cache: ${{ steps.node.outputs.cache }} + + - name: Authenticate npm against GitHub Packages + if: env.NODE_AUTH_TOKEN != '' + run: echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.npmrc + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: latest + + # The oldest PHP the plugin supports, so the vendor directory pup build installs runs on it. + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '7.4' + extensions: mbstring, intl + coverage: none + + # For a build step that uses it, such as the POT file below. Remove both when nothing does. + - name: Set up WP-CLI + run: | + curl -fsSLO https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar + chmod +x wp-cli.phar + sudo mv wp-cli.phar /usr/local/bin/wp + + # The name before the build, so the bucket can be asked for it. Until the toolbox publishes a + # v1 tag, pin a commit: stellarwp/plugin-toolbox/.github/actions/zip-name@. + - uses: stellarwp/plugin-toolbox/.github/actions/zip-name@v1 + id: name + with: + production: ${{ inputs.production }} + prerelease: ${{ inputs.prerelease }} + sequence: ${{ inputs.sequence }} + expected-version: ${{ inputs.expected_version }} + + # A default QA build is skipped when the bucket already has it, which is what the bot relies + # on. An alpha, beta or rc build always builds: the release dashboard links the artifact of + # the run itself, and a run that skipped the build would have none. The utility fails the step + # when the object is missing, so the outcome is the answer. + - name: Check whether the bucket already has the zip + id: s3_zip_exists + if: env.S3_CONFIGURED == 'true' && inputs.production == 'no' && steps.name.outputs.prerelease == 'dev' + continue-on-error: true + uses: the-events-calendar/action-s3-utility@2323d7371bb7c2675361dbaa2892127135c87cfb # main, 2025-11-11 + env: + S3_BUCKET: ${{ secrets.ZIP_S3_BUCKET }} + S3_ACCESS_KEY_ID: ${{ secrets.ZIP_S3_ACCESS_KEY_ID }} + S3_SECRET_ACCESS_KEY: ${{ secrets.ZIP_S3_SECRET_ACCESS_KEY }} + S3_REGION: ${{ secrets.ZIP_S3_REGION }} + S3_ENDPOINT: ${{ secrets.ZIP_S3_ENDPOINT }} + COMMAND: exists + FILE: ${{ steps.name.outputs.zip-name }}.zip + + # The plugin's own steps before packaging go here, under the same condition. Give generates + # the POT file, which pup i18n does not: `pup i18n` downloads finished translations. + # - name: Generate the POT file + # if: steps.s3_zip_exists.outcome != 'success' + # run: php -d xdebug.mode=off "$(command -v wp)" i18n make-pot . languages/my-plugin.pot + + - uses: stellarwp/plugin-toolbox/.github/actions/release-zip@v1 + id: zip + if: steps.s3_zip_exists.outcome != 'success' + with: + version: ${{ steps.name.outputs.version }} + production: ${{ inputs.production }} + env: + # For a build that installs private composer packages. Empty without the secret. + COMPOSER_AUTH: >- + ${{ secrets.COMPOSER_TOKEN != '' && format('{{"github-oauth": {{"github.com": "{0}"}}}}', secrets.COMPOSER_TOKEN) || '' }} + + - name: Stage the zip for the bucket + if: env.S3_CONFIGURED == 'true' && steps.zip.outcome == 'success' + env: + ZIP_PATH: ${{ steps.zip.outputs.zip-path }} + run: | + mkdir zip_files + cp "$ZIP_PATH" zip_files + + - name: Upload the zip to the bucket + if: env.S3_CONFIGURED == 'true' && steps.zip.outcome == 'success' + uses: the-events-calendar/action-s3-utility@2323d7371bb7c2675361dbaa2892127135c87cfb # main, 2025-11-11 + with: + args: --acl public-read --follow-symlinks + env: + S3_BUCKET: ${{ secrets.ZIP_S3_BUCKET }} + S3_ACCESS_KEY_ID: ${{ secrets.ZIP_S3_ACCESS_KEY_ID }} + S3_SECRET_ACCESS_KEY: ${{ secrets.ZIP_S3_SECRET_ACCESS_KEY }} + S3_REGION: ${{ secrets.ZIP_S3_REGION }} + S3_ENDPOINT: ${{ secrets.ZIP_S3_ENDPOINT }} + COMMAND: sync + SOURCE_DIR: zip_files + + # Also when the bucket already had the zip: the bot asked for a link, and the link is the same. + # A notice that fails is a warning on the run, not a failed build. + - name: Tell Slack where the zip is + if: env.SLACK_CONFIGURED == 'true' && inputs.slack_channel != '' && inputs.slack_thread != '' + continue-on-error: true + env: + CHANNEL: ${{ inputs.slack_channel }} + THREAD: ${{ inputs.slack_thread }} + FILE: ${{ steps.name.outputs.zip-name }}.zip + SECRET: ${{ secrets.SLACK_PACKAGING_SECRET }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + curl -fsS -G https://utility.theeventscalendar.com/slack-message.php \ + --data-urlencode "channel=$CHANNEL" \ + --data-urlencode "thread=$THREAD" \ + --data-urlencode "file=$FILE" \ + --data-urlencode "secret=$SECRET" \ + --data-urlencode "url=$RUN_URL" + + # The release dashboard's "RC ZIP" link opens this run's page, where the artifact downloads. + - uses: actions/upload-artifact@v7 + if: steps.zip.outcome == 'success' + with: + name: ${{ steps.name.outputs.zip-name }} + path: ${{ steps.zip.outputs.zip-path }} + retention-days: 30 From 991fb2d0d67eeb6b54fc9425768250d5d71b4d6f Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Wed, 4 Nov 2026 14:01:07 -0300 Subject: [PATCH 05/19] README: zip-name and release-zip in Phase 2, the Phase 3 zip.yml as their wrapper --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3dc4dbf..93ac9b8 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Three drawers in the toolbox. You can use any one without the others. | Layer | Lives in | What's in it | |---|---|---| -| Composite actions | `.github/actions/` | Small single-purpose steps with no opinions: `setup-slic`, `setup-bun`, `setup-node`, `prepare-matrix`, `check-php-changes`, `smart-checkout`, `process-changelog`, `generate-pot` | +| Composite actions | `.github/actions/` | Small single-purpose steps with no opinions: `setup-slic`, `setup-bun`, `setup-node`, `prepare-matrix`, `check-php-changes`, `smart-checkout`, `process-changelog`, `generate-pot`, `zip-name`, `release-zip` | | Reusable workflows | `.github/workflows/` | Full jobs built from the actions (standards, analysis, tests, zip, release prep). Most repos will only touch this layer. | | File sync | `templates/` + `.github/sync.yml` | Files that must exist in the target repo, grouped by brand and repo type | @@ -170,6 +170,7 @@ Each phase ends with something people can actually use. - [x] Add `release-prep`: version bump, TBD replacement and changelog through pup and the changelogger action, committed to the release branch through the GitHub API. A caller to copy is in `templates/workflows/release-prep.yml`. - [x] Add `release-merge`: the release branch into the base branch, the base back into develop, each step safe to rerun and a conflict reported with a `MERGE_CONFLICT` line. - [x] Add `release-publish`: the GitHub release for a version at a commit, with a title, notes from a file and assets uploaded before the release is public, skipped when it already exists. The complete deploy workflow to copy, both actions with the build between them, is `templates/workflows/release.yml`. +- [x] Add `zip-name` and `release-zip`: the zip's name before the build, with a pre-release label (`dev`, `alpha`, `beta`, `rc`) and an optional build number, then `pup build`, `check`, `i18n` and `package` for that version. What `stellarwp/github-actions`' `zip.yml` does, as two actions so the bucket can be asked for the name first. The caller to copy, with the S3 check, the upload, the Slack notice and the artifact around them, is `templates/workflows/zip.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`. @@ -180,7 +181,7 @@ Each phase ends with something people can actually use. - [ ] `analysis.yml`: PHPStan and the PHP compat matrix. - [ ] `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. +- [ ] `zip.yml`: a `workflow_call` wrapper for the `zip-name` and `release-zip` actions, so the copies of `templates/workflows/zip.yml` become one `uses:` line. What LearnDash's version adds on top (the size comparison, the pull request comment) is brand policy and stays in its file. - [ ] `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. From a04bffb5e5085b8c26d1ea236eb4828feb0b43eb Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Wed, 4 Nov 2026 16:27:58 -0300 Subject: [PATCH 06/19] Reuse the phar an earlier step of the job downloaded, and leave none behind when a download fails zip-name and release-zip run in the same job and each downloaded pup. The phar now lives at one path per release in the runner's temp directory; a step that finds it there with content reuses it and skips the release check, and says so in the log. A download that fails removes what curl wrote, so an empty or half-written phar is never reused. release-prep gets the same through downloadPhar. --- .github/actions/release-prep/release-prep.js | 2 +- .github/actions/release-zip/release-zip.js | 2 +- .github/actions/zip-name/zip-name.js | 2 +- .github/lib/pup.js | 68 +++++++++++++++++--- tests/actions/zip-name/zip-name.test.js | 40 ++++++++++++ 5 files changed, 102 insertions(+), 12 deletions(-) diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index ee09113..f25480f 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -577,7 +577,7 @@ async function runPrepare({ github, core, exec, env, cwd = process.cwd(), fetchI * @returns {Promise} Resolves once pup has run. */ async function runPrepareWithPhar({ core, exec, cwd, env, pharRelease, version, branch }) { - const phar = await downloadPhar({ exec, env, release: pharRelease }) + const phar = await downloadPhar({ exec, env, release: pharRelease, core }) const pup = pupRunner(exec, phar) // A version lower than the one the branch already has would bump it backwards: a wrong version, or diff --git a/.github/actions/release-zip/release-zip.js b/.github/actions/release-zip/release-zip.js index 20533d7..581c850 100644 --- a/.github/actions/release-zip/release-zip.js +++ b/.github/actions/release-zip/release-zip.js @@ -144,7 +144,7 @@ async function runZip({ core, exec, env, cwd = process.cwd(), fetchImpl = global readVersionFiles(readCheckoutFile(cwd, '.puprc'), WHAT) - const { pup } = await setupPup({ exec, env, fetchImpl, what: WHAT }) + const { pup } = await setupPup({ exec, env, core, fetchImpl, what: WHAT }) for (const args of pupCommands({ version, production, check, i18n })) { await pup(args, { cwd }) diff --git a/.github/actions/zip-name/zip-name.js b/.github/actions/zip-name/zip-name.js index 671fe78..297323a 100644 --- a/.github/actions/zip-name/zip-name.js +++ b/.github/actions/zip-name/zip-name.js @@ -250,7 +250,7 @@ async function runZipName({ core, exec, env, cwd = process.cwd(), fetchImpl = gl ) } - const { pup } = await setupPup({ exec, env, fetchImpl, what: WHAT }) + const { pup } = await setupPup({ exec, env, core, fetchImpl, what: WHAT }) const baseVersion = readPrintedVersion( await pup(['get-version'], { cwd, silent: true, noticesToStderr: true }) diff --git a/.github/lib/pup.js b/.github/lib/pup.js index 730b5cf..6226e5c 100644 --- a/.github/lib/pup.js +++ b/.github/lib/pup.js @@ -13,6 +13,7 @@ * means a change in the actions that call it. */ +const fs = require('node:fs') const os = require('node:os') const path = require('node:path') @@ -219,22 +220,66 @@ function noPharError(release, what) { } /** - * Downloads the phar of a pup release into the runner's temp directory, so it never ends up in the - * checkout or in a commit. + * Where the phar of a pup release lives on the runner: its temp directory, so it never ends up in + * the checkout or in a commit, and the same path for every action of the job, so one download + * serves them all. + * + * @param {object} env The step 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 phar an earlier step downloaded is there to reuse. An empty file is not: it is what a + * download that was cut off leaves. + * + * @param {string} phar The path. + * + * @returns {boolean} True for a file with content. + */ +function pharExists(phar) { + try { + return fs.statSync(phar).size > 0 + } catch { + return false + } +} + +/** + * Downloads the phar of a pup release into the runner's temp directory, unless an earlier step of + * the job already did. A download that fails leaves no file behind, so the next step does not + * reuse half a phar. * * @param {object} options * @param {object} options.exec The @actions/exec toolkit. * @param {object} options.env The step environment, for RUNNER_TEMP. * @param {string} options.release The resolved pup release tag. + * @param {object} options.core The @actions/core toolkit, to say when the phar is reused. * * @throws {Error} When the download fails. * - * @returns {Promise} The path of the downloaded phar. + * @returns {Promise} The path of the phar. */ -async function downloadPhar({ exec, env, release }) { - const phar = path.join(env.RUNNER_TEMP || os.tmpdir(), `pup-${release}.phar`) +async function downloadPhar({ exec, env, release, core }) { + const phar = pharPath(env, release) + + if (pharExists(phar)) { + core?.info(`Reusing pup ${release}, downloaded by an earlier step of this job.`) - await runOrFail(exec, 'curl', ['-fsSL', '--retry', '3', '-o', phar, pupDownloadUrl(release)], {}, `Downloading pup ${release}`) + return phar + } + + 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 phar } @@ -270,6 +315,7 @@ function pupRunner(exec, phar) { * @param {object} options.exec The @actions/exec toolkit. * @param {object} options.env The step environment: INPUT_PUP_VERSION, INPUT_TOKEN and * RUNNER_TEMP. + * @param {object} options.core The @actions/core toolkit, for the log. * @param {Function} options.fetchImpl What `fetch` to use for `latest`; a test passes a stand-in. * @param {string} options.what What was not done, to close each error message. * @@ -279,7 +325,7 @@ function pupRunner(exec, phar) { * @returns {Promise<{release: string, pup: Function}>} The release that runs and the function to * run it with, as pupRunner builds it. */ -async function setupPup({ exec, env, fetchImpl = globalThis.fetch, what = 'Nothing was changed.' }) { +async function setupPup({ exec, env, core, fetchImpl = globalThis.fetch, what = 'Nothing was changed.' }) { const release = await resolvePupRelease({ version: validatePupVersion(env.INPUT_PUP_VERSION), token: env.INPUT_TOKEN || '', @@ -287,11 +333,13 @@ async function setupPup({ exec, env, fetchImpl = globalThis.fetch, what = 'Nothi what, }) - if ((await resolvePupDistribution({ exec, release, what })) !== 'phar') { + // A phar already on the runner is proof enough that the release ships one: the check of the + // release page is for the first download of the job. + if (!pharExists(pharPath(env, release)) && (await resolvePupDistribution({ exec, release, what })) !== 'phar') { throw noPharError(release, what) } - const phar = await downloadPhar({ exec, env, release }) + const phar = await downloadPhar({ exec, env, release, core }) return { release, pup: pupRunner(exec, phar) } } @@ -322,6 +370,8 @@ module.exports = { pupReleaseUrl, resolvePupDistribution, noPharError, + pharPath, + pharExists, downloadPhar, pupRunner, setupPup, diff --git a/tests/actions/zip-name/zip-name.test.js b/tests/actions/zip-name/zip-name.test.js index b187625..ebd6517 100644 --- a/tests/actions/zip-name/zip-name.test.js +++ b/tests/actions/zip-name/zip-name.test.js @@ -460,6 +460,46 @@ describe('zip-name', () => { assert.equal(outputs().version, `4.19.0.1-dev-${TIMESTAMP}-${HASH}`) }) + it('reuses the phar an earlier step of the job downloaded, without checking or downloading again', async (t) => { + const { core, outputs, logged } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC }) + const runnerTemp = fs.mkdtempSync(path.join(os.tmpdir(), 'zip-name-runner-')) + fs.writeFileSync(path.join(runnerTemp, 'pup-2.0.0.phar'), ' call.command === 'curl'), `no curl at all, got ${JSON.stringify(sequence(calls))}`) + assert.ok(sequence(calls)[0].startsWith(`php -d display_errors=stderr ${path.join(runnerTemp, 'pup-2.0.0.phar')} get-version`)) + assert.match(logged(), /Reusing pup 2\.0\.0, downloaded by an earlier step/) + assert.equal(outputs()['base-version'], '4.19.0.1') + }) + + it('downloads again over an empty phar a cut-off download left, and leaves none behind when the download fails', async (t) => { + const runnerTemp = fs.mkdtempSync(path.join(os.tmpdir(), 'zip-name-runner-')) + const phar = path.join(runnerTemp, 'pup-2.0.0.phar') + fs.writeFileSync(phar, '') + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([ + { + command: 'curl', + args: /fsSL/, + // curl writes what it got before the connection dropped, then exits non-zero. + effect: () => fs.writeFileSync(phar, ' call.startsWith('curl -fsSL')), 'an empty phar is not reused') + assert.equal(fs.existsSync(phar), false, 'the half-written phar is removed') + }) + it('gives a real commit the suffix pup get-version --dev gives it', async (t) => { // The two git reads run for real, against a repository made here; pup stays a stand-in. What // DevSuffix.php would build from the same commit is computed alongside and has to match. From 9cc3ba6347a3ad20aa0fd06a986e9f01788651c3 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Wed, 4 Nov 2026 16:27:58 -0300 Subject: [PATCH 07/19] READMEs: the .puprc example names its regex, pup-version among the refusals, the phar reuse The zip-name example had "strategy" where pup reads "regex", so a copy would not have worked. Both zip READMEs list an invalid pup-version with the other refusals that happen before any download, in the order the code checks them. The three READMEs say a phar from an earlier step is reused. --- .github/actions/release-prep/README.md | 2 +- .github/actions/release-zip/README.md | 7 ++++--- .github/actions/zip-name/README.md | 6 +++++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md index 9de2620..cced099 100644 --- a/.github/actions/release-prep/README.md +++ b/.github/actions/release-prep/README.md @@ -201,7 +201,7 @@ 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. +release's page. A phar an earlier step of the same job downloaded is reused. 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 diff --git a/.github/actions/release-zip/README.md b/.github/actions/release-zip/README.md index b047dc0..efff9a4 100644 --- a/.github/actions/release-zip/README.md +++ b/.github/actions/release-zip/README.md @@ -94,6 +94,7 @@ Before anything is downloaded or run: - An empty `version`. The template passes zip-name's `version` output, which is never empty. - `production`, `check` or `i18n` that is not `yes` or `no`. - A missing or unusable `.puprc`. +- `pup-version` that is neither a release number such as `2.0.0` nor `latest`. After the build, a zip that is not where pup said it would be: `pup package` exited 0 but `.zip` is not in the root of the checkout. That points at `zip_name` in `.puprc`, or the @@ -122,9 +123,9 @@ name in `composer.json`. `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`. The release has to publish a `pup.phar` asset, which -every release from 1.2.4 to 2.0.0 does; one without it fails the step and names a pin to use. How -pup is resolved, downloaded and run is shared with release-prep and zip-name, in -[`.github/lib/pup.js`](../../lib/pup.js). +every release from 1.2.4 to 2.0.0 does; one without it fails the step and names a pin to use. The +phar zip-name downloaded earlier in the job is reused. How pup is resolved, downloaded and run is +shared with release-prep and zip-name, in [`.github/lib/pup.js`](../../lib/pup.js). ## How it runs diff --git a/.github/actions/zip-name/README.md b/.github/actions/zip-name/README.md index 492c1a2..6a537de 100644 --- a/.github/actions/zip-name/README.md +++ b/.github/actions/zip-name/README.md @@ -67,7 +67,7 @@ A `.puprc` with the version files and the zip name: "versions": [ { "file": "give.php", "regex": "(Version: )(.+)" }, { "file": "give.php", "regex": "(define\\( 'GIVE_VERSION', ')([^']+)" }, - { "file": "readme.txt", "strategy": "(Stable tag: )(.+)" } + { "file": "readme.txt", "regex": "(Stable tag: )(.+)" } ] } } @@ -89,6 +89,7 @@ Before anything is downloaded or run: - `prerelease` that is not `dev`, `alpha`, `beta` or `rc`. Empty counts as `dev`. - `sequence` that is not empty or digits from 1 with no leading zero: `0`, `02`, `abc`, `rc-2`. - A missing `.puprc`, one that is not JSON, or one with no `paths.versions`. +- `pup-version` that is neither a release number such as `2.0.0` nor `latest`. After the version is read, before the suffix is built: @@ -139,6 +140,9 @@ into the runner's temp directory and runs on the runner's PHP. A release without supported yet: the 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". +A phar an earlier step of the same job downloaded is reused, so release-zip after zip-name +downloads nothing; a download that fails leaves no file behind to be reused by mistake. + How pup is resolved, downloaded and run is shared with release-prep and release-zip, in [`.github/lib/pup.js`](../../lib/pup.js). When the TypeScript line of pup publishes a distribution, that file changes and this action runs it without an edit of its own. Pass release-zip the same From 98e28003e60243dbdbdf0484b2e8ce3b193b74bf Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Wed, 4 Nov 2026 16:27:58 -0300 Subject: [PATCH 08/19] Say in the zip template why it has no concurrency group --- templates/workflows/zip.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/templates/workflows/zip.yml b/templates/workflows/zip.yml index 0f76ab8..7f05e68 100644 --- a/templates/workflows/zip.yml +++ b/templates/workflows/zip.yml @@ -69,6 +69,10 @@ on: permissions: contents: read +# No concurrency group: two runs of the same commit build the same zip under the same name, and the +# bucket sync writes the same bytes twice; two pre-release runs are two artifacts, which is the point. +# Nothing here is written to a branch or a tag. + jobs: zip: # pup runs on the PHP set up below, which also serves the plugin's own build. Give runs on From fcea06e88de748b250de5490461938cfb58b6939 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Wed, 4 Nov 2026 16:34:37 -0300 Subject: [PATCH 09/19] Take the checkout's directory as working-directory in zip-name and release-zip A composite action's steps run in the workspace and a uses: step takes no working-directory, so a workflow that checks the repository out into source/ (Plugin Check does) could not call the two actions. Both take an optional working-directory, relative to the workspace and empty for the workspace itself: the .puprc, the version files and the commit are read there, pup runs there and writes the zip there, and zip-path stays an absolute path. A directory that is not there is refused before anything is downloaded or run. --- .github/actions/release-zip/README.md | 2 + .github/actions/release-zip/action.yml | 9 ++++ .github/actions/release-zip/release-zip.js | 7 ++- .github/actions/zip-name/README.md | 2 + .github/actions/zip-name/action.yml | 9 ++++ .github/actions/zip-name/zip-name.js | 13 +++-- .github/lib/workspace.js | 48 +++++++++++++++++++ tests/actions/release-zip/release-zip.test.js | 29 +++++++++++ tests/actions/zip-name/zip-name.test.js | 30 ++++++++++++ 9 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 .github/lib/workspace.js diff --git a/.github/actions/release-zip/README.md b/.github/actions/release-zip/README.md index efff9a4..d66bebd 100644 --- a/.github/actions/release-zip/README.md +++ b/.github/actions/release-zip/README.md @@ -93,6 +93,7 @@ Before anything is downloaded or run: - An empty `version`. The template passes zip-name's `version` output, which is never empty. - `production`, `check` or `i18n` that is not `yes` or `no`. +- `working-directory` that is not a directory of the workspace. - A missing or unusable `.puprc`. - `pup-version` that is neither a release number such as `2.0.0` nor `latest`. @@ -108,6 +109,7 @@ name in `composer.json`. | `production` | no | `no` | `yes` runs `pup check` strict; `no` runs it with `--dev` | | `check` | no | `yes` | Whether `pup check` runs | | `i18n` | no | `yes` | Whether `pup i18n` runs | +| `working-directory` | no | the workspace | Where the repository is checked out, relative to the workspace, e.g. `source`. pup runs and writes the zip there; `zip-path` is absolute either way | | `pup-version` | no | `latest` | The pup release to run: a number such as `2.0.0`, or `latest`. Pass the same value zip-name got. See [Which pup runs](#which-pup-runs) | | `token` | no | `github.token` | Authenticates the `latest` lookup. A pinned `pup-version` never uses it | diff --git a/.github/actions/release-zip/action.yml b/.github/actions/release-zip/action.yml index 69d4c7a..cf14069 100644 --- a/.github/actions/release-zip/action.yml +++ b/.github/actions/release-zip/action.yml @@ -31,6 +31,14 @@ inputs: description: '`yes` runs `pup i18n`, which downloads the finished translations; `no` skips it. Anything else fails before anything runs.' required: false default: 'yes' + working-directory: + description: >- + Where the repository is checked out, relative to the workspace, for a workflow that checks it + out somewhere other than the workspace itself (`source/`). pup runs there and writes the zip + there; `zip-path` is still an absolute path. Empty is the workspace. A directory that is not + there fails before anything runs. + required: false + default: '' pup-version: description: >- The pup release that builds and packages: a release number, such as 2.0.0, or 'latest' for the @@ -71,6 +79,7 @@ runs: INPUT_PRODUCTION: ${{ inputs.production }} INPUT_CHECK: ${{ inputs.check }} INPUT_I18N: ${{ inputs.i18n }} + INPUT_WORKING_DIRECTORY: ${{ inputs.working-directory }} INPUT_PUP_VERSION: ${{ inputs.pup-version }} INPUT_TOKEN: ${{ inputs.token }} with: diff --git a/.github/actions/release-zip/release-zip.js b/.github/actions/release-zip/release-zip.js index 581c850..0ede66b 100644 --- a/.github/actions/release-zip/release-zip.js +++ b/.github/actions/release-zip/release-zip.js @@ -16,6 +16,7 @@ const path = require('node:path') const { readVersionFiles } = require('../../lib/puprc.js') const { setupPup, lastLine } = require('../../lib/pup.js') +const { readWorkingDirectory } = require('../../lib/workspace.js') /** What was not done, to close every error message. */ const WHAT = 'No zip was built.' @@ -128,7 +129,8 @@ function readCheckoutFile(cwd, name) { * @param {object} options.core The @actions/core toolkit, for the log and the outputs. * @param {object} options.exec The @actions/exec toolkit. * @param {object} options.env The environment the action manifest put its inputs in. - * @param {string} options.cwd The checkout, the working directory by default. + * @param {string} options.cwd The workspace, the process's working directory by default; + * `working-directory` is resolved against it. * @param {Function} options.fetchImpl What `fetch` to use for `latest`; a test passes a stand-in. * * @throws {Error} When an input is wrong, .puprc is unusable, the pup release cannot be run, a pup @@ -136,11 +138,12 @@ function readCheckoutFile(cwd, name) { * * @returns {Promise} Resolves once the zip exists and the outputs are set. */ -async function runZip({ core, exec, env, cwd = process.cwd(), fetchImpl = globalThis.fetch }) { +async function runZip({ core, exec, env, cwd: workspace = process.cwd(), fetchImpl = globalThis.fetch }) { const version = readVersion(env.INPUT_VERSION) const production = readYesNo(env.INPUT_PRODUCTION, 'production') const check = readYesNo(env.INPUT_CHECK, 'check') const i18n = readYesNo(env.INPUT_I18N, 'i18n') + const cwd = readWorkingDirectory(env.INPUT_WORKING_DIRECTORY, { base: workspace, what: WHAT }) readVersionFiles(readCheckoutFile(cwd, '.puprc'), WHAT) diff --git a/.github/actions/zip-name/README.md b/.github/actions/zip-name/README.md index 6a537de..30d75a3 100644 --- a/.github/actions/zip-name/README.md +++ b/.github/actions/zip-name/README.md @@ -88,6 +88,7 @@ Before anything is downloaded or run: action checks the value itself, so a dispatch by hand or by a tool gets the same answer. - `prerelease` that is not `dev`, `alpha`, `beta` or `rc`. Empty counts as `dev`. - `sequence` that is not empty or digits from 1 with no leading zero: `0`, `02`, `abc`, `rc-2`. +- `working-directory` that is not a directory of the workspace. - A missing `.puprc`, one that is not JSON, or one with no `paths.versions`. - `pup-version` that is neither a release number such as `2.0.0` nor `latest`. @@ -115,6 +116,7 @@ habit still produces the right production build. | `prerelease` | no | `dev` | The label of a QA build: `dev`, `alpha`, `beta` or `rc` | | `sequence` | no | | The number of a build numbered by hand, in place of the timestamp and hash | | `expected-version` | no | | The version the files have to hold; see [What it refuses](#what-it-refuses) | +| `working-directory` | no | the workspace | Where the repository is checked out, relative to the workspace, e.g. `source`. The `.puprc`, the version files and the commit are read there | | `pup-version` | no | `latest` | The pup release to run: a number such as `2.0.0`, or `latest`. See [Which pup runs](#which-pup-runs) | | `token` | no | `github.token` | Authenticates the `latest` lookup. A pinned `pup-version` never uses it | diff --git a/.github/actions/zip-name/action.yml b/.github/actions/zip-name/action.yml index c62d6fa..19e5c74 100644 --- a/.github/actions/zip-name/action.yml +++ b/.github/actions/zip-name/action.yml @@ -38,6 +38,14 @@ inputs: wrong ref stops before it starts. required: false default: '' + working-directory: + description: >- + Where the repository is checked out, relative to the workspace, for a workflow that checks it + out somewhere other than the workspace itself (`source/`). The .puprc, the version files and + the commit are read there. Empty is the workspace. A directory that is not there fails before + anything runs. + required: false + default: '' pup-version: description: >- The pup release that reads the version and names the zip: a release number, such as 2.0.0, @@ -86,6 +94,7 @@ runs: INPUT_PRERELEASE: ${{ inputs.prerelease }} INPUT_SEQUENCE: ${{ inputs.sequence }} INPUT_EXPECTED_VERSION: ${{ inputs.expected-version }} + INPUT_WORKING_DIRECTORY: ${{ inputs.working-directory }} INPUT_PUP_VERSION: ${{ inputs.pup-version }} INPUT_TOKEN: ${{ inputs.token }} with: diff --git a/.github/actions/zip-name/zip-name.js b/.github/actions/zip-name/zip-name.js index 297323a..e27d372 100644 --- a/.github/actions/zip-name/zip-name.js +++ b/.github/actions/zip-name/zip-name.js @@ -19,6 +19,7 @@ const path = require('node:path') const { runOrFail } = require('../../lib/exec.js') const { readVersionFiles } = require('../../lib/puprc.js') const { setupPup, lastLine } = require('../../lib/pup.js') +const { readWorkingDirectory } = require('../../lib/workspace.js') /** What was not done, to close every error message. */ const WHAT = 'No zip name was resolved.' @@ -208,9 +209,9 @@ function readCheckoutFile(cwd, name) { * the zip name. * * Every input check and the .puprc read run before pup is downloaded, so a wrong `production`, - * `prerelease` or `sequence`, or a repository without a usable .puprc, fails with nothing - * downloaded and nothing run. `expected-version` is checked once the version is read, before the - * suffix is built. + * `prerelease` or `sequence`, a `working-directory` that is not there, or a repository without a + * usable .puprc, fails with nothing downloaded and nothing run. `expected-version` is checked once + * the version is read, before the suffix is built. * * `production` is the master switch: with `yes`, `prerelease` and `sequence` are ignored with a * warning, never refused, so a form filled in by habit still produces the clean zip. @@ -219,7 +220,8 @@ function readCheckoutFile(cwd, name) { * @param {object} options.core The @actions/core toolkit, for the log and the outputs. * @param {object} options.exec The @actions/exec toolkit. * @param {object} options.env The environment the action manifest put its inputs in. - * @param {string} options.cwd The checkout, the working directory by default. + * @param {string} options.cwd The workspace, the process's working directory by default; + * `working-directory` is resolved against it. * @param {Function} options.fetchImpl What `fetch` to use for `latest`; a test passes a stand-in. * * @throws {Error} When an input is wrong, .puprc is unusable, the pup release cannot be run, pup @@ -228,11 +230,12 @@ function readCheckoutFile(cwd, name) { * * @returns {Promise} Resolves once the outputs are set. */ -async function runZipName({ core, exec, env, cwd = process.cwd(), fetchImpl = globalThis.fetch }) { +async function runZipName({ core, exec, env, cwd: workspace = process.cwd(), fetchImpl = globalThis.fetch }) { const production = readYesNo(env.INPUT_PRODUCTION, 'production') const prerelease = readPrerelease(env.INPUT_PRERELEASE) const sequence = readSequence(env.INPUT_SEQUENCE) const expected = String(env.INPUT_EXPECTED_VERSION ?? '').trim() + const cwd = readWorkingDirectory(env.INPUT_WORKING_DIRECTORY, { base: workspace, what: WHAT }) readVersionFiles(readCheckoutFile(cwd, '.puprc'), WHAT) diff --git a/.github/lib/workspace.js b/.github/lib/workspace.js new file mode 100644 index 0000000..479af5b --- /dev/null +++ b/.github/lib/workspace.js @@ -0,0 +1,48 @@ +'use strict' + +/** + * The directory an action works in, shared by the toolbox actions. Loaded by a path relative to each + * action's directory, like versions.js. + * + * A composite action's steps run in the workspace, and a `uses:` step takes no `working-directory`, + * so an action that reads a checkout made somewhere else (`source/`, as a workflow that keeps the + * plugin apart from its own tooling does) takes the directory as an input. + */ + +const fs = require('node:fs') +const path = require('node:path') + +/** + * Reads the `working-directory` input: where the checkout is, relative to the workspace. + * + * @param {string} input The input value. Empty is the workspace itself. + * @param {object} options + * @param {string} options.base The workspace, the process's working directory by default. + * @param {string} options.what What was not done, to close the error message. + * + * @throws {Error} When the directory does not exist, or is not a directory. + * + * @returns {string} The directory, as an absolute path. + */ +function readWorkingDirectory(input, { base = process.cwd(), what }) { + const trimmed = String(input ?? '').trim() + const dir = path.resolve(base, trimmed === '' ? '.' : trimmed) + + let stat + try { + stat = fs.statSync(dir) + } catch { + stat = null + } + + if (!stat?.isDirectory()) { + throw new Error( + `working-directory '${trimmed}' is not a directory of the workspace (${dir}). Pass the directory the ` + + `repository is checked out in, relative to the workspace, or leave it empty for the workspace itself. ${what}` + ) + } + + return dir +} + +module.exports = { readWorkingDirectory } diff --git a/tests/actions/release-zip/release-zip.test.js b/tests/actions/release-zip/release-zip.test.js index c73217d..406181c 100644 --- a/tests/actions/release-zip/release-zip.test.js +++ b/tests/actions/release-zip/release-zip.test.js @@ -213,6 +213,35 @@ describe('release-zip', () => { ) }) + it('builds in working-directory and reports the zip there, as an absolute path', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const workspace = checkoutWith({}) + fs.mkdirSync(path.join(workspace, 'source')) + fs.writeFileSync(path.join(workspace, 'source', '.puprc'), JSON.stringify(PUPRC)) + + await runZip({ core, exec, env: zipEnv({ INPUT_WORKING_DIRECTORY: 'source' }), cwd: workspace }) + + assert.ok( + calls.filter((call) => call.command === 'php').every((call) => call.options.cwd === path.join(workspace, 'source')), + 'pup runs in source/' + ) + assert.equal(outputs()['zip-path'], path.join(workspace, 'source', `give.${VERSION}.zip`)) + }) + + it('refuses a working-directory that is not there, before downloading or running anything', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await assert.rejects( + runZip({ core, exec, env: zipEnv({ INPUT_WORKING_DIRECTORY: 'missing' }), cwd }), + /working-directory 'missing' is not a directory of the workspace.*No zip was built/s + ) + assert.deepEqual(calls, []) + assert.deepEqual(outputs(), {}) + }) + it('stops before downloading or running anything when an input is wrong', async (t) => { const cases = [ [{ INPUT_VERSION: '' }, /No version was given/], diff --git a/tests/actions/zip-name/zip-name.test.js b/tests/actions/zip-name/zip-name.test.js index ebd6517..e355f55 100644 --- a/tests/actions/zip-name/zip-name.test.js +++ b/tests/actions/zip-name/zip-name.test.js @@ -460,6 +460,36 @@ describe('zip-name', () => { assert.equal(outputs().version, `4.19.0.1-dev-${TIMESTAMP}-${HASH}`) }) + it('reads the checkout in working-directory, resolved against the workspace', async (t) => { + // A workflow that checks the repository out into source/ and runs the action from the workspace. + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const workspace = checkoutWith({}) + fs.mkdirSync(path.join(workspace, 'source')) + fs.writeFileSync(path.join(workspace, 'source', '.puprc'), JSON.stringify(PUPRC)) + + await runZipName({ core, exec, env: nameEnv({ INPUT_WORKING_DIRECTORY: 'source' }), cwd: workspace }) + + assert.ok( + calls.filter((call) => call.command !== 'curl').every((call) => call.options.cwd === path.join(workspace, 'source')), + 'pup and git run in source/' + ) + assert.equal(outputs()['base-version'], '4.19.0.1') + }) + + it('refuses a working-directory that is not there, before downloading or running anything', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await assert.rejects( + runZipName({ core, exec, env: nameEnv({ INPUT_WORKING_DIRECTORY: 'source' }), cwd }), + /working-directory 'source' is not a directory of the workspace.*No zip name was resolved/s + ) + assert.deepEqual(calls, []) + assert.deepEqual(outputs(), {}) + }) + it('reuses the phar an earlier step of the job downloaded, without checking or downloading again', async (t) => { const { core, outputs, logged } = await coreWithOutputs(t) const { exec, calls } = recordingExec(allSucceed()) From 923926f2816bf5da5fd709fc13cdcddf2ed90a72 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Wed, 4 Nov 2026 17:03:17 -0300 Subject: [PATCH 10/19] One readCheckoutFile and one readYesNo, in the lib readCheckoutFile was the same function in release-prep, zip-name and release-zip; readYesNo the same in the two zip actions. The first joins readWorkingDirectory in workspace.js, the second is .github/lib/inputs.js with the closing line as a parameter; each action keeps a one-line wrapper with its own line. The .puprc read that only validates says so. --- .github/actions/release-prep/release-prep.js | 25 +---------- .github/actions/release-zip/release-zip.js | 39 +++-------------- .github/actions/zip-name/zip-name.js | 46 +++----------------- .github/lib/inputs.js | 35 +++++++++++++++ .github/lib/workspace.js | 24 +++++++++- 5 files changed, 71 insertions(+), 98 deletions(-) create mode 100644 .github/lib/inputs.js diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index f25480f..7cb946a 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -11,8 +11,6 @@ * the API calls, so a test can pass stand-ins and assert the calls and the requests. */ -const fs = require('node:fs') -const path = require('node:path') const { execFileSync } = require('node:child_process') // The three-or-four-parts pattern lives with the other version rules, shared with release-merge. @@ -31,6 +29,7 @@ const { pupRunner, lastLine, } = require('../../lib/pup.js') +const { readCheckoutFile } = require('../../lib/workspace.js') /** * A semver version as semver.org defines it: MAJOR.MINOR.PATCH, an optional pre-release and @@ -412,28 +411,6 @@ function numericPrefix(version) { return match ? match[0] : null } -/** - * 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 - } -} - /** * Refuses a version the repository has already tagged. * diff --git a/.github/actions/release-zip/release-zip.js b/.github/actions/release-zip/release-zip.js index 0ede66b..576676e 100644 --- a/.github/actions/release-zip/release-zip.js +++ b/.github/actions/release-zip/release-zip.js @@ -14,9 +14,10 @@ const fs = require('node:fs') const path = require('node:path') +const { readYesNo: readYesNoInput } = require('../../lib/inputs.js') const { readVersionFiles } = require('../../lib/puprc.js') const { setupPup, lastLine } = require('../../lib/pup.js') -const { readWorkingDirectory } = require('../../lib/workspace.js') +const { readWorkingDirectory, readCheckoutFile } = require('../../lib/workspace.js') /** What was not done, to close every error message. */ const WHAT = 'No zip was built.' @@ -43,7 +44,7 @@ function readVersion(version) { } /** - * Reads a yes/no input. + * Reads a yes/no input: the shared rule, with this action's closing line. * * @param {string} value The input value. * @param {string} input The input's name, for the message. @@ -53,15 +54,7 @@ function readVersion(version) { * @returns {boolean} True for `yes`. */ function readYesNo(value, input) { - const trimmed = String(value ?? '') - .trim() - .toLowerCase() - - if (trimmed !== 'yes' && trimmed !== 'no') { - throw new Error(`${input} '${String(value ?? '').trim()}' is not yes or no. ${WHAT}`) - } - - return trimmed === 'yes' + return readYesNoInput(value, input, WHAT) } /** @@ -95,28 +88,6 @@ function pupCommands({ version, production, check, i18n }) { return commands } -/** - * 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 - } -} - /** * Validates the inputs, then builds and packages the version with pup. * @@ -145,6 +116,8 @@ async function runZip({ core, exec, env, cwd: workspace = process.cwd(), fetchIm const i18n = readYesNo(env.INPUT_I18N, 'i18n') const cwd = readWorkingDirectory(env.INPUT_WORKING_DIRECTORY, { base: workspace, what: WHAT }) + // Only the check: pup reads the files itself. A .puprc pup would read as empty is refused here, + // before pup is downloaded. readVersionFiles(readCheckoutFile(cwd, '.puprc'), WHAT) const { pup } = await setupPup({ exec, env, core, fetchImpl, what: WHAT }) diff --git a/.github/actions/zip-name/zip-name.js b/.github/actions/zip-name/zip-name.js index e27d372..e1a8a5c 100644 --- a/.github/actions/zip-name/zip-name.js +++ b/.github/actions/zip-name/zip-name.js @@ -13,13 +13,11 @@ * commands run through `exec`, so a test can pass stand-ins and assert the calls. */ -const fs = require('node:fs') -const path = require('node:path') - const { runOrFail } = require('../../lib/exec.js') +const { readYesNo: readYesNoInput } = require('../../lib/inputs.js') const { readVersionFiles } = require('../../lib/puprc.js') const { setupPup, lastLine } = require('../../lib/pup.js') -const { readWorkingDirectory } = require('../../lib/workspace.js') +const { readWorkingDirectory, readCheckoutFile } = require('../../lib/workspace.js') /** What was not done, to close every error message. */ const WHAT = 'No zip name was resolved.' @@ -41,11 +39,7 @@ const DEV_SUFFIX_COMMANDS = [ ] /** - * Reads a yes/no input. - * - * The workflow declares these as a `choice` of yes/no rather than a boolean: the packaging bot - * sends `yes` and `no`, and a boolean input would make it send `true`. The action checks the - * value itself, so a dispatch by hand or by a tool gets the same refusal. + * Reads a yes/no input: the shared rule, with this action's closing line. * * @param {string} value The input value. * @param {string} input The input's name, for the message. @@ -55,15 +49,7 @@ const DEV_SUFFIX_COMMANDS = [ * @returns {boolean} True for `yes`. */ function readYesNo(value, input) { - const trimmed = String(value ?? '') - .trim() - .toLowerCase() - - if (trimmed !== 'yes' && trimmed !== 'no') { - throw new Error(`${input} '${String(value ?? '').trim()}' is not yes or no. ${WHAT}`) - } - - return trimmed === 'yes' + return readYesNoInput(value, input, WHAT) } /** @@ -182,28 +168,6 @@ async function readCommitStamp(exec, cwd) { return [printed[0], printed[1]] } -/** - * 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 - } -} - /** * Validates the inputs, reads the version the checkout holds, builds the suffix and asks pup for * the zip name. @@ -237,6 +201,8 @@ async function runZipName({ core, exec, env, cwd: workspace = process.cwd(), fet const expected = String(env.INPUT_EXPECTED_VERSION ?? '').trim() const cwd = readWorkingDirectory(env.INPUT_WORKING_DIRECTORY, { base: workspace, what: WHAT }) + // Only the check: pup reads the files itself. A .puprc pup would read as empty is refused here, + // before pup is downloaded. readVersionFiles(readCheckoutFile(cwd, '.puprc'), WHAT) if (production && (prerelease !== 'dev' || sequence !== null)) { diff --git a/.github/lib/inputs.js b/.github/lib/inputs.js new file mode 100644 index 0000000..7328fba --- /dev/null +++ b/.github/lib/inputs.js @@ -0,0 +1,35 @@ +'use strict' + +/** + * Reading action inputs that several actions share. Loaded by a path relative to each action's + * directory, like versions.js. + */ + +/** + * Reads a yes/no input. + * + * A caller workflow declares these as a `choice` of yes/no rather than a boolean: the packaging bot + * only sends `yes` and `no` for choice inputs, and a boolean input would make it send `true`. The + * action checks the value itself, so a dispatch by hand or by a tool gets the same refusal. + * + * @param {string} value The input value. + * @param {string} input The input's name, for the message. + * @param {string} what What was not done, to close the message. + * + * @throws {Error} When it is neither `yes` nor `no`. + * + * @returns {boolean} True for `yes`. + */ +function readYesNo(value, input, what) { + const trimmed = String(value ?? '') + .trim() + .toLowerCase() + + if (trimmed !== 'yes' && trimmed !== 'no') { + throw new Error(`${input} '${String(value ?? '').trim()}' is not yes or no. ${what}`) + } + + return trimmed === 'yes' +} + +module.exports = { readYesNo } diff --git a/.github/lib/workspace.js b/.github/lib/workspace.js index 479af5b..883745f 100644 --- a/.github/lib/workspace.js +++ b/.github/lib/workspace.js @@ -45,4 +45,26 @@ function readWorkingDirectory(input, { base = process.cwd(), what }) { return dir } -module.exports = { readWorkingDirectory } +/** + * 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 + } +} + +module.exports = { readWorkingDirectory, readCheckoutFile } From 8baf066703c796f6146fa77b606d194a8f15991b Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Wed, 4 Nov 2026 17:03:59 -0300 Subject: [PATCH 11/19] Hand the GitHub Packages token only to the two steps that use it NODE_AUTH_TOKEN and BUN_AUTH_TOKEN sat in the job's env, so every step saw them, the third-party setup actions and the bucket utility's container included. The job keeps a boolean, as it does for the bucket and Slack; the token goes to the step that writes the .npmrc and to release-zip, whose pup build runs npm or bun. --- templates/workflows/zip.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/templates/workflows/zip.yml b/templates/workflows/zip.yml index 7f05e68..13aa9f4 100644 --- a/templates/workflows/zip.yml +++ b/templates/workflows/zip.yml @@ -84,9 +84,9 @@ jobs: # artifact; only the upload and the notice are skipped. S3_CONFIGURED: ${{ secrets.ZIP_S3_BUCKET != '' }} SLACK_CONFIGURED: ${{ secrets.SLACK_PACKAGING_SECRET != '' }} - # For a build that installs packages from GitHub Packages. Both are empty without the secret. - NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} - BUN_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} + # For a build that installs packages from GitHub Packages. The token itself goes only to the + # two steps that use it, below, not to every step of the job. + NPM_AUTH_CONFIGURED: ${{ secrets.NODE_AUTH_TOKEN != '' }} steps: - uses: actions/checkout@v6 with: @@ -113,7 +113,9 @@ jobs: cache: ${{ steps.node.outputs.cache }} - name: Authenticate npm against GitHub Packages - if: env.NODE_AUTH_TOKEN != '' + if: env.NPM_AUTH_CONFIGURED == 'true' + env: + NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} run: echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.npmrc - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 @@ -175,9 +177,12 @@ jobs: version: ${{ steps.name.outputs.version }} production: ${{ inputs.production }} env: - # For a build that installs private composer packages. Empty without the secret. + # For a build that installs private composer packages or packages from GitHub Packages. + # All three are empty without their secret. COMPOSER_AUTH: >- ${{ secrets.COMPOSER_TOKEN != '' && format('{{"github-oauth": {{"github.com": "{0}"}}}}', secrets.COMPOSER_TOKEN) || '' }} + NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} + BUN_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} - name: Stage the zip for the bucket if: env.S3_CONFIGURED == 'true' && steps.zip.outcome == 'success' From 85f98bf8a47b69797915de0d9e88892d93f0f986 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Wed, 4 Nov 2026 17:03:59 -0300 Subject: [PATCH 12/19] pupCommands lists the pup arguments, since pup() adds php and the phar The helper still returned php and the phar in front of each command, and the loop dropped them to call pup(). It returns what pup() takes; the test of the helper follows. The commands that run are the same, which the runPrepare test asserts. --- .github/actions/release-prep/release-prep.js | 17 +++++++---------- tests/actions/release-prep/release-prep.test.js | 6 +++--- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index 7cb946a..a1a996f 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -329,17 +329,16 @@ function readChangeloggerStrategy(text) { } /** - * The pup commands that prepare a version, in the order they run. + * The pup commands that prepare a version, in the order they run, as the arguments `pup()` takes. * - * @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. + * @returns {string[][]} The version bump, then the TBD replacement. */ -function pupCommands(phar, version) { +function pupCommands(version) { return [ - { command: 'php', args: [phar, 'replace-version', version] }, - { command: 'php', args: [phar, 'replace-tbd', version] }, + ['replace-version', version], + ['replace-tbd', version], ] } @@ -583,10 +582,8 @@ async function runPrepareWithPhar({ core, exec, cwd, env, pharRelease, version, ) } - for (const { args } of pupCommands(phar, version)) { - // Each entry names the phar first, as the command line runs it; pup() adds that itself. - const [, ...pupArgs] = args - await pup(pupArgs, { cwd }) + for (const args of pupCommands(version)) { + await pup(args, { cwd }) } } diff --git a/tests/actions/release-prep/release-prep.test.js b/tests/actions/release-prep/release-prep.test.js index b0e5818..84c40ac 100644 --- a/tests/actions/release-prep/release-prep.test.js +++ b/tests/actions/release-prep/release-prep.test.js @@ -488,9 +488,9 @@ describe('release-prep', () => { }) 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'] }, + assert.deepEqual(pupCommands('4.17.0'), [ + ['replace-version', '4.17.0'], + ['replace-tbd', '4.17.0'], ]) }) }) From a34cc15638345b5e845c5d3a1151bbf752c69cb6 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Wed, 4 Nov 2026 17:37:19 -0300 Subject: [PATCH 13/19] Ignore prerelease and sequence on a production build whatever they hold They were read, and refused when wrong, before the branch that ignores them, so a typo in the sequence field held up a production zip. production is the master switch: on yes the two are not read; the warning names the raw values the form carried. --- .github/actions/zip-name/README.md | 13 ++++++++----- .github/actions/zip-name/action.yml | 6 ++++-- .github/actions/zip-name/zip-name.js | 26 ++++++++++++++++--------- tests/actions/zip-name/zip-name.test.js | 17 ++++++++++++++++ 4 files changed, 46 insertions(+), 16 deletions(-) diff --git a/.github/actions/zip-name/README.md b/.github/actions/zip-name/README.md index 30d75a3..4b64d61 100644 --- a/.github/actions/zip-name/README.md +++ b/.github/actions/zip-name/README.md @@ -86,8 +86,10 @@ Before anything is downloaded or run: - `production` that is not `yes` or `no`. The template declares it as a `choice` of the two, but the action checks the value itself, so a dispatch by hand or by a tool gets the same answer. -- `prerelease` that is not `dev`, `alpha`, `beta` or `rc`. Empty counts as `dev`. -- `sequence` that is not empty or digits from 1 with no leading zero: `0`, `02`, `abc`, `rc-2`. +- On a QA build (`production: no`), `prerelease` that is not `dev`, `alpha`, `beta` or `rc`. Empty + counts as `dev`. +- On a QA build, `sequence` that is not empty or digits from 1 with no leading zero: `0`, `02`, + `abc`, `rc-2`. - `working-directory` that is not a directory of the workspace. - A missing `.puprc`, one that is not JSON, or one with no `paths.versions`. - `pup-version` that is neither a release number such as `2.0.0` nor `latest`. @@ -104,9 +106,10 @@ After the version is read, before the suffix is built: And at the end, a name from `pup zip-name` that does not end in `.`, which means the name is not the one the S3 check and the upload would look for. -With `production: yes`, `prerelease` and `sequence` are ignored, not refused: the action writes a -warning saying so, and names the clean zip. `production` is the master switch; a form filled in by -habit still produces the right production build. +With `production: yes`, `prerelease` and `sequence` are ignored whatever they hold, not read and +not refused: the action writes a warning naming what the form carried, and names the clean zip. +`production` is the master switch; a form filled in by habit, or with a typo in `sequence`, still +produces the right production build. ## Inputs diff --git a/.github/actions/zip-name/action.yml b/.github/actions/zip-name/action.yml index 19e5c74..3da1675 100644 --- a/.github/actions/zip-name/action.yml +++ b/.github/actions/zip-name/action.yml @@ -21,14 +21,16 @@ inputs: description: >- The label a QA build is named with: `dev`, `alpha`, `beta` or `rc`. `dev` with no `sequence` is `-dev--`, the suffix `pup get-version --dev` gives, so the default name is - the one every QA build had until now. Anything else fails before anything runs. + the one every QA build had until now. On a QA build anything else fails before anything runs; + a production build ignores it. required: false default: 'dev' sequence: description: >- The number of a build numbered by hand, digits from 1 with no leading zero: `2` names the build `-rc-2`. Empty names it after the commit, `-rc--`, unique - per commit. Anything else fails before anything runs. + per commit. On a QA build anything else fails before anything runs; a production build + ignores it. required: false default: '' expected-version: diff --git a/.github/actions/zip-name/zip-name.js b/.github/actions/zip-name/zip-name.js index e1a8a5c..06c7d8c 100644 --- a/.github/actions/zip-name/zip-name.js +++ b/.github/actions/zip-name/zip-name.js @@ -196,8 +196,11 @@ async function readCommitStamp(exec, cwd) { */ async function runZipName({ core, exec, env, cwd: workspace = process.cwd(), fetchImpl = globalThis.fetch }) { const production = readYesNo(env.INPUT_PRODUCTION, 'production') - const prerelease = readPrerelease(env.INPUT_PRERELEASE) - const sequence = readSequence(env.INPUT_SEQUENCE) + // production is the master switch: a production build ignores prerelease and sequence whatever + // they hold, so a form filled in by habit, or with a typo, never holds up a production zip. They + // are read, and refused when wrong, on a QA build only. + const prerelease = production ? 'dev' : readPrerelease(env.INPUT_PRERELEASE) + const sequence = production ? null : readSequence(env.INPUT_SEQUENCE) const expected = String(env.INPUT_EXPECTED_VERSION ?? '').trim() const cwd = readWorkingDirectory(env.INPUT_WORKING_DIRECTORY, { base: workspace, what: WHAT }) @@ -205,18 +208,23 @@ async function runZipName({ core, exec, env, cwd: workspace = process.cwd(), fet // before pup is downloaded. readVersionFiles(readCheckoutFile(cwd, '.puprc'), WHAT) - if (production && (prerelease !== 'dev' || sequence !== null)) { + if (production) { + // The raw values, since they were not read: the warning names what the form carried. + const rawPrerelease = String(env.INPUT_PRERELEASE ?? '').trim() + const rawSequence = String(env.INPUT_SEQUENCE ?? '').trim() const ignored = [ - prerelease !== 'dev' ? `prerelease '${prerelease}'` : null, - sequence !== null ? `sequence '${sequence}'` : null, + rawPrerelease !== '' && rawPrerelease.toLowerCase() !== 'dev' ? `prerelease '${rawPrerelease}'` : null, + rawSequence !== '' ? `sequence '${rawSequence}'` : null, ] .filter(Boolean) .join(' and ') - core.warning( - `production is yes, so ${ignored} ${ignored.includes(' and ') ? 'are' : 'is'} ignored: a production ` + - 'build is the clean ..zip. Set production to no for a pre-release build.' - ) + if (ignored !== '') { + core.warning( + `production is yes, so ${ignored} ${ignored.includes(' and ') ? 'are' : 'is'} ignored: a production ` + + 'build is the clean ..zip. Set production to no for a pre-release build.' + ) + } } const { pup } = await setupPup({ exec, env, core, fetchImpl, what: WHAT }) diff --git a/tests/actions/zip-name/zip-name.test.js b/tests/actions/zip-name/zip-name.test.js index e355f55..f84d74a 100644 --- a/tests/actions/zip-name/zip-name.test.js +++ b/tests/actions/zip-name/zip-name.test.js @@ -300,6 +300,23 @@ describe('zip-name', () => { } }) + it('ignores a prerelease or sequence it would refuse on a QA build, when production is yes', async (t) => { + // production is the master switch: a typo in the form must not hold up a production zip. + const { core, outputs, logged } = await coreWithOutputs(t) + const { exec } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await runZipName({ core, exec, env: nameEnv({ INPUT_PRODUCTION: 'yes', INPUT_PRERELEASE: 'nightly', INPUT_SEQUENCE: 'abc' }), cwd }) + + assert.match(logged(), /::warning::production is yes, so prerelease 'nightly' and sequence 'abc' are ignored/) + assert.deepEqual(outputs(), { + 'base-version': '4.19.0.1', + version: '4.19.0.1', + 'zip-name': 'give.4.19.0.1', + prerelease: '', + }) + }) + it('stops before downloading or running anything when production, prerelease or sequence is wrong', async (t) => { const cases = [ [{ INPUT_PRODUCTION: 'true' }, /production 'true' is not yes or no/], From cb007c7f9bccdccce8b0e41813e89968c3340626 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Wed, 4 Nov 2026 17:37:39 -0300 Subject: [PATCH 14/19] Say to keep one numbering scheme for a version's pre-release cycle version_compare reads the part after the label as a number, so a numbered build sorts below a timestamped one of the same label and a site on the timestamped RC is not offered the numbered one. The sequence input says so in the manifest, the README's table and its naming section. --- .github/actions/zip-name/README.md | 7 +++++-- .github/actions/zip-name/action.yml | 6 ++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/actions/zip-name/README.md b/.github/actions/zip-name/README.md index 4b64d61..4c58982 100644 --- a/.github/actions/zip-name/README.md +++ b/.github/actions/zip-name/README.md @@ -49,7 +49,10 @@ the suffix `pup get-version --dev` gives: the committer timestamp of HEAD and it read with the same two git commands pup runs, so a default QA build is named as every QA build was until now. The other labels take the place of `dev`. A `sequence` takes the place of the timestamp and the hash, for a build numbered by hand, `rc-1`, `rc-2`; nothing counts up on its own, since -that would need state somewhere. +that would need state somewhere. Keep one scheme for a version's pre-release cycle: `version_compare` +reads the part after the label as a number, so `4.19.0.1-rc-2` sorts below +`4.19.0.1-rc-1792689067-fbcf8681`, and a site that installed the timestamped RC is not offered the +numbered one. Hyphens throughout, as pup writes them. In PHP's `version_compare`, which WordPress uses to decide whether an update applies, `-`, `.` and `_` are the same separator and `dev < alpha < beta < RC < @@ -117,7 +120,7 @@ produces the right production build. |---|---|---|---| | `production` | no | `no` | `yes` names the clean zip and ignores the two below; `no` names a QA build | | `prerelease` | no | `dev` | The label of a QA build: `dev`, `alpha`, `beta` or `rc` | -| `sequence` | no | | The number of a build numbered by hand, in place of the timestamp and hash | +| `sequence` | no | | The number of a build numbered by hand, in place of the timestamp and hash. One scheme per pre-release cycle; see [What it names](#what-it-names) | | `expected-version` | no | | The version the files have to hold; see [What it refuses](#what-it-refuses) | | `working-directory` | no | the workspace | Where the repository is checked out, relative to the workspace, e.g. `source`. The `.puprc`, the version files and the commit are read there | | `pup-version` | no | `latest` | The pup release to run: a number such as `2.0.0`, or `latest`. See [Which pup runs](#which-pup-runs) | diff --git a/.github/actions/zip-name/action.yml b/.github/actions/zip-name/action.yml index 3da1675..af80cc0 100644 --- a/.github/actions/zip-name/action.yml +++ b/.github/actions/zip-name/action.yml @@ -29,8 +29,10 @@ inputs: description: >- The number of a build numbered by hand, digits from 1 with no leading zero: `2` names the build `-rc-2`. Empty names it after the commit, `-rc--`, unique - per commit. On a QA build anything else fails before anything runs; a production build - ignores it. + per commit. Keep one scheme for a version's pre-release cycle: a numbered build sorts below a + timestamped one of the same label (`-rc-2` is below `-rc-1792689067-fbcf8681`), so a site on + the timestamped build is not offered the numbered one. On a QA build anything else fails + before anything runs; a production build ignores it. required: false default: '' expected-version: From f8df3116cf2101bbc14c1136d6739a256420a42f Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Wed, 4 Nov 2026 17:38:17 -0300 Subject: [PATCH 15/19] Say that working-directory may be absolute, and whose value it is The descriptions said relative to the workspace; the input takes an absolute path too, for a checkout under the runner's temp, and the lib's comment says why it is not kept inside the workspace: the value is the caller's own, with the trust the checkout's ref has. --- .github/actions/release-zip/README.md | 4 ++-- .github/actions/release-zip/action.yml | 8 ++++---- .github/actions/zip-name/README.md | 4 ++-- .github/actions/zip-name/action.yml | 8 ++++---- .github/lib/workspace.js | 10 +++++++--- tests/actions/release-zip/release-zip.test.js | 2 +- tests/actions/zip-name/zip-name.test.js | 2 +- 7 files changed, 21 insertions(+), 17 deletions(-) diff --git a/.github/actions/release-zip/README.md b/.github/actions/release-zip/README.md index d66bebd..a7fafe9 100644 --- a/.github/actions/release-zip/README.md +++ b/.github/actions/release-zip/README.md @@ -93,7 +93,7 @@ Before anything is downloaded or run: - An empty `version`. The template passes zip-name's `version` output, which is never empty. - `production`, `check` or `i18n` that is not `yes` or `no`. -- `working-directory` that is not a directory of the workspace. +- `working-directory` that is not a directory. - A missing or unusable `.puprc`. - `pup-version` that is neither a release number such as `2.0.0` nor `latest`. @@ -109,7 +109,7 @@ name in `composer.json`. | `production` | no | `no` | `yes` runs `pup check` strict; `no` runs it with `--dev` | | `check` | no | `yes` | Whether `pup check` runs | | `i18n` | no | `yes` | Whether `pup i18n` runs | -| `working-directory` | no | the workspace | Where the repository is checked out, relative to the workspace, e.g. `source`. pup runs and writes the zip there; `zip-path` is absolute either way | +| `working-directory` | no | the workspace | Where the repository is checked out, relative to the workspace or absolute, e.g. `source`. pup runs and writes the zip there; `zip-path` is absolute either way. The caller's own value, trusted like the checkout's `ref` | | `pup-version` | no | `latest` | The pup release to run: a number such as `2.0.0`, or `latest`. Pass the same value zip-name got. See [Which pup runs](#which-pup-runs) | | `token` | no | `github.token` | Authenticates the `latest` lookup. A pinned `pup-version` never uses it | diff --git a/.github/actions/release-zip/action.yml b/.github/actions/release-zip/action.yml index cf14069..a670d48 100644 --- a/.github/actions/release-zip/action.yml +++ b/.github/actions/release-zip/action.yml @@ -33,10 +33,10 @@ inputs: default: 'yes' working-directory: description: >- - Where the repository is checked out, relative to the workspace, for a workflow that checks it - out somewhere other than the workspace itself (`source/`). pup runs there and writes the zip - there; `zip-path` is still an absolute path. Empty is the workspace. A directory that is not - there fails before anything runs. + Where the repository is checked out, relative to the workspace or absolute, for a workflow + that checks it out somewhere other than the workspace itself (`source/`, or a directory under + the runner's temp). pup runs there and writes the zip there; `zip-path` is still an absolute + path. Empty is the workspace. A directory that is not there fails before anything runs. required: false default: '' pup-version: diff --git a/.github/actions/zip-name/README.md b/.github/actions/zip-name/README.md index 4c58982..5ace3bd 100644 --- a/.github/actions/zip-name/README.md +++ b/.github/actions/zip-name/README.md @@ -93,7 +93,7 @@ Before anything is downloaded or run: counts as `dev`. - On a QA build, `sequence` that is not empty or digits from 1 with no leading zero: `0`, `02`, `abc`, `rc-2`. -- `working-directory` that is not a directory of the workspace. +- `working-directory` that is not a directory. - A missing `.puprc`, one that is not JSON, or one with no `paths.versions`. - `pup-version` that is neither a release number such as `2.0.0` nor `latest`. @@ -122,7 +122,7 @@ produces the right production build. | `prerelease` | no | `dev` | The label of a QA build: `dev`, `alpha`, `beta` or `rc` | | `sequence` | no | | The number of a build numbered by hand, in place of the timestamp and hash. One scheme per pre-release cycle; see [What it names](#what-it-names) | | `expected-version` | no | | The version the files have to hold; see [What it refuses](#what-it-refuses) | -| `working-directory` | no | the workspace | Where the repository is checked out, relative to the workspace, e.g. `source`. The `.puprc`, the version files and the commit are read there | +| `working-directory` | no | the workspace | Where the repository is checked out, relative to the workspace or absolute, e.g. `source`. The `.puprc`, the version files and the commit are read there. The caller's own value, trusted like the checkout's `ref` | | `pup-version` | no | `latest` | The pup release to run: a number such as `2.0.0`, or `latest`. See [Which pup runs](#which-pup-runs) | | `token` | no | `github.token` | Authenticates the `latest` lookup. A pinned `pup-version` never uses it | diff --git a/.github/actions/zip-name/action.yml b/.github/actions/zip-name/action.yml index af80cc0..7867674 100644 --- a/.github/actions/zip-name/action.yml +++ b/.github/actions/zip-name/action.yml @@ -44,10 +44,10 @@ inputs: default: '' working-directory: description: >- - Where the repository is checked out, relative to the workspace, for a workflow that checks it - out somewhere other than the workspace itself (`source/`). The .puprc, the version files and - the commit are read there. Empty is the workspace. A directory that is not there fails before - anything runs. + Where the repository is checked out, relative to the workspace or absolute, for a workflow + that checks it out somewhere other than the workspace itself (`source/`, or a directory under + the runner's temp). The .puprc, the version files and the commit are read there. Empty is the + workspace. A directory that is not there fails before anything runs. required: false default: '' pup-version: diff --git a/.github/lib/workspace.js b/.github/lib/workspace.js index 883745f..eb78a3a 100644 --- a/.github/lib/workspace.js +++ b/.github/lib/workspace.js @@ -7,13 +7,17 @@ * A composite action's steps run in the workspace, and a `uses:` step takes no `working-directory`, * so an action that reads a checkout made somewhere else (`source/`, as a workflow that keeps the * plugin apart from its own tooling does) takes the directory as an input. + * + * The value is the caller's own `with:`, written by whoever writes the workflow, so it carries the + * same trust as the `ref` and `path` of the checkout step in the same job; it is not kept inside + * the workspace. An absolute path is a legitimate case: a checkout in the runner's temp directory. */ const fs = require('node:fs') const path = require('node:path') /** - * Reads the `working-directory` input: where the checkout is, relative to the workspace. + * Reads the `working-directory` input: where the checkout is, relative to the workspace or absolute. * * @param {string} input The input value. Empty is the workspace itself. * @param {object} options @@ -37,8 +41,8 @@ function readWorkingDirectory(input, { base = process.cwd(), what }) { if (!stat?.isDirectory()) { throw new Error( - `working-directory '${trimmed}' is not a directory of the workspace (${dir}). Pass the directory the ` + - `repository is checked out in, relative to the workspace, or leave it empty for the workspace itself. ${what}` + `working-directory '${trimmed}' is not a directory (${dir}). Pass the directory the repository is ` + + `checked out in, relative to the workspace or absolute, or leave it empty for the workspace itself. ${what}` ) } diff --git a/tests/actions/release-zip/release-zip.test.js b/tests/actions/release-zip/release-zip.test.js index 406181c..17f0224 100644 --- a/tests/actions/release-zip/release-zip.test.js +++ b/tests/actions/release-zip/release-zip.test.js @@ -236,7 +236,7 @@ describe('release-zip', () => { await assert.rejects( runZip({ core, exec, env: zipEnv({ INPUT_WORKING_DIRECTORY: 'missing' }), cwd }), - /working-directory 'missing' is not a directory of the workspace.*No zip was built/s + /working-directory 'missing' is not a directory \(.*\/missing\).*No zip was built/s ) assert.deepEqual(calls, []) assert.deepEqual(outputs(), {}) diff --git a/tests/actions/zip-name/zip-name.test.js b/tests/actions/zip-name/zip-name.test.js index f84d74a..ac5f6c1 100644 --- a/tests/actions/zip-name/zip-name.test.js +++ b/tests/actions/zip-name/zip-name.test.js @@ -501,7 +501,7 @@ describe('zip-name', () => { await assert.rejects( runZipName({ core, exec, env: nameEnv({ INPUT_WORKING_DIRECTORY: 'source' }), cwd }), - /working-directory 'source' is not a directory of the workspace.*No zip name was resolved/s + /working-directory 'source' is not a directory \(.*\/source\).*No zip name was resolved/s ) assert.deepEqual(calls, []) assert.deepEqual(outputs(), {}) From b57b8301040dc62f8a43d0785adfca50258a2f1d Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Wed, 4 Nov 2026 20:10:26 -0300 Subject: [PATCH 16/19] zip-name publishes the pup release it resolved, and release-zip takes it With latest on both, a pup release published between the two steps gave the name and the package different pups. zip-name's pup-release output is what pup-version resolved to; the template and the snippets pass it to release-zip as its pup-version, so latest is resolved once per job and the phar is reused for sure. --- .github/actions/release-zip/README.md | 9 ++++++--- .github/actions/release-zip/action.yml | 5 +++-- .github/actions/zip-name/README.md | 7 +++++-- .github/actions/zip-name/action.yml | 6 ++++++ .github/actions/zip-name/zip-name.js | 5 ++++- templates/workflows/zip.yml | 2 ++ tests/actions/zip-name/zip-name.test.js | 7 +++++++ 7 files changed, 33 insertions(+), 8 deletions(-) diff --git a/.github/actions/release-zip/README.md b/.github/actions/release-zip/README.md index a7fafe9..a2f3dbe 100644 --- a/.github/actions/release-zip/README.md +++ b/.github/actions/release-zip/README.md @@ -14,6 +14,7 @@ Builds and packages a version of the plugin with pup, from the root of the check with: version: ${{ steps.name.outputs.version }} production: ${{ inputs.production }} + pup-version: ${{ steps.name.outputs.pup-release }} - uses: actions/upload-artifact@v7 with: @@ -110,7 +111,7 @@ name in `composer.json`. | `check` | no | `yes` | Whether `pup check` runs | | `i18n` | no | `yes` | Whether `pup i18n` runs | | `working-directory` | no | the workspace | Where the repository is checked out, relative to the workspace or absolute, e.g. `source`. pup runs and writes the zip there; `zip-path` is absolute either way. The caller's own value, trusted like the checkout's `ref` | -| `pup-version` | no | `latest` | The pup release to run: a number such as `2.0.0`, or `latest`. Pass the same value zip-name got. See [Which pup runs](#which-pup-runs) | +| `pup-version` | no | `latest` | The pup release to run: a number such as `2.0.0`, or `latest`. Pass zip-name's `pup-release` output, the release that named the zip. See [Which pup runs](#which-pup-runs) | | `token` | no | `github.token` | Authenticates the `latest` lookup. A pinned `pup-version` never uses it | ## Outputs @@ -125,8 +126,10 @@ name in `composer.json`. `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`. The release has to publish a `pup.phar` asset, which -every release from 1.2.4 to 2.0.0 does; one without it fails the step and names a pin to use. The -phar zip-name downloaded earlier in the job is reused. How pup is resolved, downloaded and run is +every release from 1.2.4 to 2.0.0 does; one without it fails the step and names a pin to use. Pass +zip-name's `pup-release` output here: the pup that named the zip packages it, and the phar it +downloaded earlier in the job is reused. With `latest` on both, a release published between the two +steps would give them a different pup. How pup is resolved, downloaded and run is shared with release-prep and zip-name, in [`.github/lib/pup.js`](../../lib/pup.js). ## How it runs diff --git a/.github/actions/release-zip/action.yml b/.github/actions/release-zip/action.yml index a670d48..c8554b4 100644 --- a/.github/actions/release-zip/action.yml +++ b/.github/actions/release-zip/action.yml @@ -43,8 +43,9 @@ inputs: description: >- The pup release that builds and packages: 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; - a release without one fails the step and names a pin to use instead. Pass the same value - zip-name got, so the zip is named by the pup that read the version. + a release without one fails the step and names a pin to use instead. Pass zip-name's + `pup-release` output, so the zip is packaged by the pup that named it even when `latest` + is a newer release by then. required: false default: 'latest' token: diff --git a/.github/actions/zip-name/README.md b/.github/actions/zip-name/README.md index 5ace3bd..8e3afa3 100644 --- a/.github/actions/zip-name/README.md +++ b/.github/actions/zip-name/README.md @@ -21,6 +21,7 @@ holds, the pre-release suffix of a QA build, and the slug pup gives the zip. with: version: ${{ steps.name.outputs.version }} production: ${{ inputs.production }} + pup-version: ${{ steps.name.outputs.pup-release }} ``` The name is known before the build so a caller can ask the S3 bucket whether that zip already @@ -134,6 +135,7 @@ produces the right production build. | `version` | `4.19.0.1-rc-1792689067-fbcf8681` | The version the build carries, suffix included. Pass it to release-zip | | `zip-name` | `give.4.19.0.1-rc-1792689067-fbcf8681` | The zip name without `.zip` | | `prerelease` | `rc` | The label the build carries. Empty for a production build, so a step can read `== 'dev'` without looking at `production` | +| `pup-release` | `2.0.0` | The pup release that read the version, what `pup-version` resolved to. Pass it to release-zip as `pup-version` | ## Which pup runs @@ -153,8 +155,9 @@ downloads nothing; a download that fails leaves no file behind to be reused by m How pup is resolved, downloaded and run is shared with release-prep and release-zip, in [`.github/lib/pup.js`](../../lib/pup.js). When the TypeScript line of pup publishes a distribution, -that file changes and this action runs it without an edit of its own. Pass release-zip the same -`pup-version`, so the zip is named by the pup that read the version. +that file changes and this action runs it without an edit of its own. Pass release-zip the +`pup-release` output as its `pup-version`, so the zip is packaged by the pup that named it: with +`latest`, a release published between the two steps would otherwise give the two a different pup. ## How it runs diff --git a/.github/actions/zip-name/action.yml b/.github/actions/zip-name/action.yml index 7867674..e7b62f9 100644 --- a/.github/actions/zip-name/action.yml +++ b/.github/actions/zip-name/action.yml @@ -80,6 +80,12 @@ outputs: prerelease: description: 'The pre-release label the build carries: `dev`, `alpha`, `beta` or `rc`. Empty for a production build.' value: ${{ steps.name.outputs.prerelease }} + pup-release: + description: >- + The pup release that read the version, e.g. `2.0.0`: what `pup-version` resolved to. Pass it to + release-zip as its `pup-version`, so the zip is packaged by the same pup that named it, even + when `latest` is a newer release by then. + value: ${{ steps.name.outputs.pup-release }} runs: using: 'composite' diff --git a/.github/actions/zip-name/zip-name.js b/.github/actions/zip-name/zip-name.js index 06c7d8c..bbe2d00 100644 --- a/.github/actions/zip-name/zip-name.js +++ b/.github/actions/zip-name/zip-name.js @@ -227,7 +227,7 @@ async function runZipName({ core, exec, env, cwd: workspace = process.cwd(), fet } } - const { pup } = await setupPup({ exec, env, core, fetchImpl, what: WHAT }) + const { pup, release } = await setupPup({ exec, env, core, fetchImpl, what: WHAT }) const baseVersion = readPrintedVersion( await pup(['get-version'], { cwd, silent: true, noticesToStderr: true }) @@ -267,6 +267,9 @@ async function runZipName({ core, exec, env, cwd: workspace = process.cwd(), fet core.setOutput('version', version) core.setOutput('zip-name', zipName) core.setOutput('prerelease', production ? '' : prerelease) + // The release `latest` resolved to, so release-zip runs the same pup, not a newer one published + // in between, and reuses the phar. + core.setOutput('pup-release', release) } module.exports = { diff --git a/templates/workflows/zip.yml b/templates/workflows/zip.yml index 13aa9f4..58a49eb 100644 --- a/templates/workflows/zip.yml +++ b/templates/workflows/zip.yml @@ -176,6 +176,8 @@ jobs: with: version: ${{ steps.name.outputs.version }} production: ${{ inputs.production }} + # The pup that named the zip packages it: `latest` is resolved once, by zip-name. + pup-version: ${{ steps.name.outputs.pup-release }} env: # For a build that installs private composer packages or packages from GitHub Packages. # All three are empty without their secret. diff --git a/tests/actions/zip-name/zip-name.test.js b/tests/actions/zip-name/zip-name.test.js index ac5f6c1..778c478 100644 --- a/tests/actions/zip-name/zip-name.test.js +++ b/tests/actions/zip-name/zip-name.test.js @@ -222,6 +222,7 @@ describe('zip-name', () => { ) assert.deepEqual(outputs(), { 'base-version': '4.19.0.1', + 'pup-release': '2.0.0', version, 'zip-name': `give.${version}`, prerelease: 'dev', @@ -237,6 +238,7 @@ describe('zip-name', () => { assert.deepEqual(outputs(), { 'base-version': '4.19.0.1', + 'pup-release': '2.0.0', version: `4.19.0.1-rc-${TIMESTAMP}-${HASH}`, 'zip-name': `give.4.19.0.1-rc-${TIMESTAMP}-${HASH}`, prerelease: 'rc', @@ -253,6 +255,7 @@ describe('zip-name', () => { assert.deepEqual(sequence(calls), [...UP_TO_VERSION, `php -d display_errors=stderr ${PHAR} zip-name 4.19.0.1-beta-2`]) assert.deepEqual(outputs(), { 'base-version': '4.19.0.1', + 'pup-release': '2.0.0', version: '4.19.0.1-beta-2', 'zip-name': 'give.4.19.0.1-beta-2', prerelease: 'beta', @@ -269,6 +272,7 @@ describe('zip-name', () => { assert.deepEqual(sequence(calls), [...UP_TO_VERSION, `php -d display_errors=stderr ${PHAR} zip-name 4.19.0.1`]) assert.deepEqual(outputs(), { 'base-version': '4.19.0.1', + 'pup-release': '2.0.0', version: '4.19.0.1', 'zip-name': 'give.4.19.0.1', prerelease: '', @@ -293,6 +297,7 @@ describe('zip-name', () => { assert.match(logged(), warning) assert.deepEqual(outputs(), { 'base-version': '4.19.0.1', + 'pup-release': '2.0.0', version: '4.19.0.1', 'zip-name': 'give.4.19.0.1', prerelease: '', @@ -311,6 +316,7 @@ describe('zip-name', () => { assert.match(logged(), /::warning::production is yes, so prerelease 'nightly' and sequence 'abc' are ignored/) assert.deepEqual(outputs(), { 'base-version': '4.19.0.1', + 'pup-release': '2.0.0', version: '4.19.0.1', 'zip-name': 'give.4.19.0.1', prerelease: '', @@ -475,6 +481,7 @@ describe('zip-name', () => { ]) assert.ok(sequence(calls).some((call) => call.includes('download/2.0.0/pup.phar'))) assert.equal(outputs().version, `4.19.0.1-dev-${TIMESTAMP}-${HASH}`) + assert.equal(outputs()['pup-release'], '2.0.0', 'the release latest resolved to, for release-zip to take') }) it('reads the checkout in working-directory, resolved against the workspace', async (t) => { From 7efcb00de0a141df6cbfab1a69c552550f07277f Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Wed, 4 Nov 2026 20:10:26 -0300 Subject: [PATCH 17/19] Say when a production build warns about prerelease and sequence The README said the action writes a warning; it writes one when prerelease is not dev or sequence is not empty, which is what the code does. --- .github/actions/zip-name/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/actions/zip-name/README.md b/.github/actions/zip-name/README.md index 8e3afa3..861ceb2 100644 --- a/.github/actions/zip-name/README.md +++ b/.github/actions/zip-name/README.md @@ -111,7 +111,8 @@ And at the end, a name from `pup zip-name` that does not end in `.`, wh is not the one the S3 check and the upload would look for. With `production: yes`, `prerelease` and `sequence` are ignored whatever they hold, not read and -not refused: the action writes a warning naming what the form carried, and names the clean zip. +not refused: when `prerelease` is not `dev` or `sequence` is not empty, the action writes a warning +naming what the form carried; either way it names the clean zip. `production` is the master switch; a form filled in by habit, or with a typo in `sequence`, still produces the right production build. From 8054bead231823b57c57bc2709df2323fb755310 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Thu, 5 Nov 2026 14:36:42 -0300 Subject: [PATCH 18/19] release-zip: run the caller's after-build commands between pup build and pup package A plugin that generates a file from its built assets and ships it in the zip had nowhere to do so: release-zip runs pup build, check, i18n and package in one go, and the template put the POT step before it. Give's POT comes from WP-CLI reading the strings of the built JavaScript, which only exists after pup build, so a POT made before the action would miss them. The reusable zip.yml in stellarwp/github-actions ran such commands between the build and the package as additional_commands. The after-build input takes shell commands and runs them in the checkout after pup build and before pup check, through bash with -e and pipefail, like a run: step. Empty runs nothing. The template passes the POT command there instead of as a step of its own. Tests: the commands run between build and check, in the checkout and in working-directory, with their output in the log; a blank input runs no shell; a failing command fails the step with its output and no pup command runs after it. --- .github/actions/release-zip/README.md | 26 +++++- .github/actions/release-zip/action.yml | 9 ++ .github/actions/release-zip/release-zip.js | 49 +++++++++-- templates/workflows/zip.yml | 13 +-- tests/actions/release-zip/release-zip.test.js | 82 ++++++++++++++++++- 5 files changed, 162 insertions(+), 17 deletions(-) diff --git a/.github/actions/release-zip/README.md b/.github/actions/release-zip/README.md index a2f3dbe..59b2b0e 100644 --- a/.github/actions/release-zip/README.md +++ b/.github/actions/release-zip/README.md @@ -39,13 +39,29 @@ Four pup commands run, in this order, from the root of the checkout: default. Skipped with `check: no`. 3. `pup i18n` downloads the finished translations the `i18n` section of `.puprc` points at. Nothing happens without the section. Skipped with `i18n: no`. It does not generate the POT file; a - plugin that ships one generates it in a step of its own, before this action. + plugin that ships one generates it with `after-build`, below. 4. `pup package ` writes `version` into every file `.puprc` lists under `paths.versions`, syncs the files `.distignore` or `.distfiles` keep into `.pup-zip/`, zips them as `.zip` in the root of the checkout, and restores the version files with git. The checkout is left as it was; only the zip carries the suffix. -A command that fails fails the step with pup's output in the log, and the commands after it do not +Between `pup build` and `pup check`, the `after-build` commands run when the caller passes any: +what a plugin generates from the built assets and ships in the zip. Give makes its POT file there, +since WP-CLI reads the strings of the built JavaScript, which is only there after the build, and +`pup package` takes the file along: + +```yaml +- uses: stellarwp/plugin-toolbox/.github/actions/release-zip@v1 + with: + version: ${{ steps.name.outputs.version }} + after-build: | + php -d xdebug.mode=off "$(command -v wp)" i18n make-pot . languages/give.pot +``` + +They run in the checkout (`working-directory` when set) through bash with `-e` and `pipefail`, as a +`run:` step does, with the step's environment; what they print goes to the log. + +A command that fails fails the step with its output in the log, and the commands after it do not run. The action then asks `pup zip-name ` for the name pup gave the zip and checks that the file is there, so the `zip-path` output always names a file that exists. @@ -110,6 +126,7 @@ name in `composer.json`. | `production` | no | `no` | `yes` runs `pup check` strict; `no` runs it with `--dev` | | `check` | no | `yes` | Whether `pup check` runs | | `i18n` | no | `yes` | Whether `pup i18n` runs | +| `after-build` | no | | Shell commands to run after `pup build` and before `pup check`, in the checkout: a POT file, say. bash with `-e` and `pipefail`, like a `run:` step. The caller's own script, trusted like its workflow | | `working-directory` | no | the workspace | Where the repository is checked out, relative to the workspace or absolute, e.g. `source`. pup runs and writes the zip there; `zip-path` is absolute either way. The caller's own value, trusted like the checkout's `ref` | | `pup-version` | no | `latest` | The pup release to run: a number such as `2.0.0`, or `latest`. Pass zip-name's `pup-release` output, the release that named the zip. See [Which pup runs](#which-pup-runs) | | `token` | no | `github.token` | Authenticates the `latest` lookup. A pinned `pup-version` never uses it | @@ -142,8 +159,9 @@ action's directory. Inputs reach the script through `process.env`, never by being interpolated into it, so a version is data and cannot become part of the program. The commands run with their arguments as a list, -without a shell. `pup build`, `check`, `i18n` and `package` print to the log; only `pup zip-name`, -whose output the action reads, runs silently. +without a shell; the one exception is `after-build`, which is a script by definition and runs +through bash, like a `run:` step. `pup build`, `check`, `i18n` and `package` and the after-build +commands print to the log; only `pup zip-name`, whose output the action reads, runs silently. `tests/actions/release-zip/release-zip.test.js` covers the rules above against a stand-in for `exec`. Run `npm install` once, then `node --test`. diff --git a/.github/actions/release-zip/action.yml b/.github/actions/release-zip/action.yml index c8554b4..a36cb3f 100644 --- a/.github/actions/release-zip/action.yml +++ b/.github/actions/release-zip/action.yml @@ -31,6 +31,14 @@ inputs: description: '`yes` runs `pup i18n`, which downloads the finished translations; `no` skips it. Anything else fails before anything runs.' required: false default: 'yes' + after-build: + description: >- + Shell commands to run after `pup build` and before `pup check`, `pup i18n` and + `pup package`, in the checkout, with the step's environment: what a plugin generates from the + built assets and ships in the zip, such as a POT file made with WP-CLI. Run by bash with `-e` + and `pipefail`, like a `run:` step. Empty runs nothing. + required: false + default: '' working-directory: description: >- Where the repository is checked out, relative to the workspace or absolute, for a workflow @@ -80,6 +88,7 @@ runs: INPUT_PRODUCTION: ${{ inputs.production }} INPUT_CHECK: ${{ inputs.check }} INPUT_I18N: ${{ inputs.i18n }} + INPUT_AFTER_BUILD: ${{ inputs.after-build }} INPUT_WORKING_DIRECTORY: ${{ inputs.working-directory }} INPUT_PUP_VERSION: ${{ inputs.pup-version }} INPUT_TOKEN: ${{ inputs.token }} diff --git a/.github/actions/release-zip/release-zip.js b/.github/actions/release-zip/release-zip.js index 576676e..61a863e 100644 --- a/.github/actions/release-zip/release-zip.js +++ b/.github/actions/release-zip/release-zip.js @@ -14,6 +14,7 @@ const fs = require('node:fs') const path = require('node:path') +const { runOrFail } = require('../../lib/exec.js') const { readYesNo: readYesNoInput } = require('../../lib/inputs.js') const { readVersionFiles } = require('../../lib/puprc.js') const { setupPup, lastLine } = require('../../lib/pup.js') @@ -57,6 +58,33 @@ function readYesNo(value, input) { return readYesNoInput(value, input, WHAT) } +/** + * Reads the commands to run after `pup build`: the text as given, or nothing when it is blank. + * + * @param {string} value The `after-build` input. + * + * @returns {string} The commands, or an empty string when there are none. + */ +function readAfterBuild(value) { + return String(value ?? '').trim() === '' ? '' : String(value) +} + +/** + * Runs the after-build commands in the checkout, the way a `run:` step would: bash with `-e` and + * `pipefail`, so the first command that fails fails the step, and with the step's environment. + * + * @param {object} exec The @actions/exec toolkit. + * @param {string} commands The shell script. + * @param {string} cwd The checkout. + * + * @throws {Error} When the script exits non-zero, holding what it printed. + * + * @returns {Promise} What the script printed to stdout. + */ +function runAfterBuild(exec, commands, cwd) { + return runOrFail(exec, 'bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', commands], { cwd }, 'The after-build commands') +} + /** * The pup commands a build runs, in order. * @@ -91,10 +119,12 @@ function pupCommands({ version, production, check, i18n }) { /** * Validates the inputs, then builds and packages the version with pup. * - * Every input check and the .puprc read run before pup is downloaded. A pup command that fails - * fails the step with pup's output, and the commands after it do not run. `COMPOSER_AUTH`, when the - * caller's step sets it, reaches the build commands through the environment, as every variable - * does. + * Every input check and the .puprc read run before pup is downloaded. The `after-build` commands, + * when there are any, run between `pup build` and the commands that follow it, so a file they + * write (a POT generated from the built assets, say) is checked and packaged. A pup command or the + * after-build script that fails fails the step with its output, and nothing after it runs. + * `COMPOSER_AUTH`, when the caller's step sets it, reaches the build commands through the + * environment, as every variable does. * * @param {object} options * @param {object} options.core The @actions/core toolkit, for the log and the outputs. @@ -114,6 +144,7 @@ async function runZip({ core, exec, env, cwd: workspace = process.cwd(), fetchIm const production = readYesNo(env.INPUT_PRODUCTION, 'production') const check = readYesNo(env.INPUT_CHECK, 'check') const i18n = readYesNo(env.INPUT_I18N, 'i18n') + const afterBuild = readAfterBuild(env.INPUT_AFTER_BUILD) const cwd = readWorkingDirectory(env.INPUT_WORKING_DIRECTORY, { base: workspace, what: WHAT }) // Only the check: pup reads the files itself. A .puprc pup would read as empty is refused here, @@ -122,7 +153,13 @@ async function runZip({ core, exec, env, cwd: workspace = process.cwd(), fetchIm const { pup } = await setupPup({ exec, env, core, fetchImpl, what: WHAT }) - for (const args of pupCommands({ version, production, check, i18n })) { + const [build, ...rest] = pupCommands({ version, production, check, i18n }) + + await pup(build, { cwd }) + if (afterBuild !== '') { + await runAfterBuild(exec, afterBuild, cwd) + } + for (const args of rest) { await pup(args, { cwd }) } @@ -142,4 +179,4 @@ async function runZip({ core, exec, env, cwd: workspace = process.cwd(), fetchIm core.setOutput('zip-path', zipPath) } -module.exports = { runZip, readVersion, readYesNo, pupCommands } +module.exports = { runZip, readVersion, readYesNo, readAfterBuild, pupCommands } diff --git a/templates/workflows/zip.yml b/templates/workflows/zip.yml index 58a49eb..9ee519f 100644 --- a/templates/workflows/zip.yml +++ b/templates/workflows/zip.yml @@ -164,12 +164,6 @@ jobs: COMMAND: exists FILE: ${{ steps.name.outputs.zip-name }}.zip - # The plugin's own steps before packaging go here, under the same condition. Give generates - # the POT file, which pup i18n does not: `pup i18n` downloads finished translations. - # - name: Generate the POT file - # if: steps.s3_zip_exists.outcome != 'success' - # run: php -d xdebug.mode=off "$(command -v wp)" i18n make-pot . languages/my-plugin.pot - - uses: stellarwp/plugin-toolbox/.github/actions/release-zip@v1 id: zip if: steps.s3_zip_exists.outcome != 'success' @@ -178,6 +172,13 @@ jobs: production: ${{ inputs.production }} # The pup that named the zip packages it: `latest` is resolved once, by zip-name. pup-version: ${{ steps.name.outputs.pup-release }} + # The plugin's own commands between `pup build` and `pup package`, for what it generates + # from the built assets and ships in the zip. Give makes the POT file here: WP-CLI reads + # the strings of the built JavaScript, so it has to run after the build, and pup i18n does + # not generate it (it downloads finished translations). Remove the input when there is + # nothing to generate. + after-build: | + php -d xdebug.mode=off "$(command -v wp)" i18n make-pot . languages/my-plugin.pot env: # For a build that installs private composer packages or packages from GitHub Packages. # All three are empty without their secret. diff --git a/tests/actions/release-zip/release-zip.test.js b/tests/actions/release-zip/release-zip.test.js index 17f0224..3c096f7 100644 --- a/tests/actions/release-zip/release-zip.test.js +++ b/tests/actions/release-zip/release-zip.test.js @@ -6,7 +6,7 @@ const fs = require('node:fs') const os = require('node:os') const path = require('node:path') -const { runZip, readVersion, readYesNo, pupCommands } = require('../../../.github/actions/release-zip/release-zip.js') +const { runZip, readVersion, readYesNo, readAfterBuild, pupCommands } = require('../../../.github/actions/release-zip/release-zip.js') const { coreWithOutputs, recordingExec } = require('../../support/actions.js') /** A .puprc that names the zip and lists one version file. */ @@ -52,6 +52,7 @@ function zipEnv(overrides = {}) { INPUT_PRODUCTION: 'no', INPUT_CHECK: 'yes', INPUT_I18N: 'yes', + INPUT_AFTER_BUILD: '', INPUT_PUP_VERSION: '2.0.0', INPUT_TOKEN: '', RUNNER_TEMP: '/runner/temp', @@ -124,6 +125,18 @@ describe('release-zip', () => { }) }) + describe('readAfterBuild', () => { + it('keeps the commands as given', () => { + assert.equal(readAfterBuild('wp i18n make-pot . languages/give.pot\n'), 'wp i18n make-pot . languages/give.pot\n') + }) + + it('reads an empty or blank input as nothing to run', () => { + assert.equal(readAfterBuild(''), '') + assert.equal(readAfterBuild(' \n'), '') + assert.equal(readAfterBuild(undefined), '') + }) + }) + describe('pupCommands', () => { it('builds, checks with --dev, fetches the translations and packages a QA build', () => { assert.deepEqual(pupCommands({ version: VERSION, production: false, check: true, i18n: true }), [ @@ -213,6 +226,73 @@ describe('release-zip', () => { ) }) + it('runs the after-build commands between pup build and pup check, in the checkout, like a run step', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed([{ command: 'bash' }])) + const cwd = checkoutWith({ '.puprc': PUPRC }) + const commands = 'php -d xdebug.mode=off "$(command -v wp)" i18n make-pot . languages/give.pot\n' + + await runZip({ core, exec, env: zipEnv({ INPUT_AFTER_BUILD: commands }), cwd }) + + assert.deepEqual(sequence(calls), [ + ...DOWNLOAD, + `php ${PHAR} build`, + `bash --noprofile --norc -eo pipefail -c ${commands}`, + `php ${PHAR} check --dev`, + `php ${PHAR} i18n`, + `php ${PHAR} package ${VERSION}`, + `php -d display_errors=stderr ${PHAR} zip-name ${VERSION}`, + ]) + const bash = calls.find((call) => call.command === 'bash') + assert.equal(bash.options.cwd, cwd, 'the commands run in the checkout') + assert.ok(!bash.options.silent, 'what they print goes to the log') + }) + + it('runs the after-build commands in working-directory', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed([{ command: 'bash' }])) + const workspace = checkoutWith() + const cwd = path.join(workspace, 'source') + fs.mkdirSync(cwd) + fs.writeFileSync(path.join(cwd, '.puprc'), JSON.stringify(PUPRC)) + + await runZip({ + core, + exec, + env: zipEnv({ INPUT_AFTER_BUILD: 'echo pot', INPUT_WORKING_DIRECTORY: 'source' }), + cwd: workspace, + }) + + assert.equal(calls.find((call) => call.command === 'bash').options.cwd, cwd) + }) + + it('runs no shell when after-build is empty', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await runZip({ core, exec, env: zipEnv({ INPUT_AFTER_BUILD: ' \n' }), cwd }) + + assert.ok(!calls.some((call) => call.command === 'bash')) + }) + + it('fails with the script output when an after-build command fails, and runs nothing after it', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([{ command: 'bash', exitCode: 1, stderr: 'Error: No such file or directory: languages' }]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC }) + + await assert.rejects( + runZip({ core, exec, env: zipEnv({ INPUT_AFTER_BUILD: 'wp i18n make-pot . languages/give.pot' }), cwd }), + /The after-build commands failed \(exit code 1\):\nError: No such file or directory: languages/ + ) + + const php = sequence(calls).filter((call) => call.startsWith('php')) + assert.deepEqual(php, [`php ${PHAR} build`], 'pup build ran, and no pup command after the failure') + assert.deepEqual(outputs(), {}) + }) + it('builds in working-directory and reports the zip there, as an absolute path', async (t) => { const { core, outputs } = await coreWithOutputs(t) const { exec, calls } = recordingExec(allSucceed()) From 67fae11d84167723e27c34ca57fc8c9100aee072 Mon Sep 17 00:00:00 2001 From: Glauber Silva Date: Fri, 6 Nov 2026 10:35:17 -0300 Subject: [PATCH 19/19] zip template: say that my-plugin in the POT path is the text domain to replace The make-pot command became a live step of the template with after-build; copied as it is, it writes languages/my-plugin.pot and the build passes. The comment now says what to put there (the plugin's text domain, give for languages/give.pot) and that the input goes away when there is nothing to generate. CodeRabbit review on PR #20. --- templates/workflows/zip.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/templates/workflows/zip.yml b/templates/workflows/zip.yml index 9ee519f..e714ee5 100644 --- a/templates/workflows/zip.yml +++ b/templates/workflows/zip.yml @@ -175,8 +175,9 @@ jobs: # The plugin's own commands between `pup build` and `pup package`, for what it generates # from the built assets and ships in the zip. Give makes the POT file here: WP-CLI reads # the strings of the built JavaScript, so it has to run after the build, and pup i18n does - # not generate it (it downloads finished translations). Remove the input when there is - # nothing to generate. + # not generate it (it downloads finished translations). Replace `my-plugin` with the + # plugin's text domain, the name wordpress.org expects for the POT (`give` writes + # `languages/give.pot`), or remove the input when there is nothing to generate. after-build: | php -d xdebug.mode=off "$(command -v wp)" i18n make-pot . languages/my-plugin.pot env: