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-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index ab09ca8..a1a996f 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -11,15 +11,25 @@ * the API calls, so a test can pass stand-ins and assert the calls and the requests. */ -const fs = require('node:fs') -const os = require('node:os') -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. 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') +const { readCheckoutFile } = require('../../lib/workspace.js') /** * A semver version as semver.org defines it: MAJOR.MINOR.PATCH, an optional pre-release and @@ -33,9 +43,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 +235,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. * @@ -393,121 +329,16 @@ function readChangeloggerStrategy(text) { } /** - * Where a pup release's phar is published. + * The pup commands that prepare a version, in the order they run, as the arguments `pup()` takes. * - * @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. - * - * @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], ] } @@ -554,24 +385,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. @@ -597,28 +410,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. * @@ -736,16 +527,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 +553,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, core }) + 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 +582,8 @@ 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(version)) { + await pup(args, { cwd }) } } diff --git a/.github/actions/release-zip/README.md b/.github/actions/release-zip/README.md new file mode 100644 index 0000000..59b2b0e --- /dev/null +++ b/.github/actions/release-zip/README.md @@ -0,0 +1,167 @@ +# 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 }} + pup-version: ${{ steps.name.outputs.pup-release }} + +- 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 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. + +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. + +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`. +- `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`. + +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 | +| `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 | + +## 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. 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 + +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; 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 new file mode 100644 index 0000000..a36cb3f --- /dev/null +++ b/.github/actions/release-zip/action.yml @@ -0,0 +1,98 @@ +# 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' + 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 + 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: + 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 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: + 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_AFTER_BUILD: ${{ inputs.after-build }} + INPUT_WORKING_DIRECTORY: ${{ inputs.working-directory }} + 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..61a863e --- /dev/null +++ b/.github/actions/release-zip/release-zip.js @@ -0,0 +1,182 @@ +'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 { 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, readCheckoutFile } = require('../../lib/workspace.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: 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. + * + * @throws {Error} When it is neither `yes` nor `no`. + * + * @returns {boolean} True for `yes`. + */ +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. + * + * `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 +} + +/** + * Validates the inputs, then builds and packages the version with pup. + * + * 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. + * @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 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 + * 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: 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 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, + // before pup is downloaded. + readVersionFiles(readCheckoutFile(cwd, '.puprc'), WHAT) + + const { pup } = await setupPup({ exec, env, core, fetchImpl, what: WHAT }) + + 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 }) + } + + // 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, readAfterBuild, pupCommands } diff --git a/.github/actions/zip-name/README.md b/.github/actions/zip-name/README.md new file mode 100644 index 0000000..861ceb2 --- /dev/null +++ b/.github/actions/zip-name/README.md @@ -0,0 +1,180 @@ +# 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 }} + 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 +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. 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 < +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", "regex": "(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. +- 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. +- 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: + +- `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 whatever they hold, not read and +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. + +## 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. 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 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 | + +## 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` | +| `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 + +`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". + +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 +`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 + +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..e7b62f9 --- /dev/null +++ b/.github/actions/zip-name/action.yml @@ -0,0 +1,113 @@ +# 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. 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. 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: + 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: '' + working-directory: + description: >- + 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: + 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 }} + 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' + 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_WORKING_DIRECTORY: ${{ inputs.working-directory }} + 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..bbe2d00 --- /dev/null +++ b/.github/actions/zip-name/zip-name.js @@ -0,0 +1,284 @@ +'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 { 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, readCheckoutFile } = require('../../lib/workspace.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 shared rule, with this action's closing line. + * + * @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) { + return readYesNoInput(value, input, WHAT) +} + +/** + * 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]] +} + +/** + * 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`, 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. + * + * @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 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 + * 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: workspace = process.cwd(), fetchImpl = globalThis.fetch }) { + const production = readYesNo(env.INPUT_PRODUCTION, 'production') + // 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 }) + + // 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) { + // 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 = [ + rawPrerelease !== '' && rawPrerelease.toLowerCase() !== 'dev' ? `prerelease '${rawPrerelease}'` : null, + rawSequence !== '' ? `sequence '${rawSequence}'` : null, + ] + .filter(Boolean) + .join(' and ') + + 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, release } = await setupPup({ exec, env, core, 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) + // 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 = { + runZipName, + readYesNo, + readPrerelease, + readSequence, + readPrintedVersion, + buildSuffix, + PRERELEASE_LABELS, + DEV_SUFFIX_COMMANDS, +} 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/pup.js b/.github/lib/pup.js new file mode 100644 index 0000000..6226e5c --- /dev/null +++ b/.github/lib/pup.js @@ -0,0 +1,379 @@ +'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 fs = require('node:fs') +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}` + ) +} + +/** + * 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 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.`) + + 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 +} + +/** + * 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 {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. + * + * @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, core, fetchImpl = globalThis.fetch, what = 'Nothing was changed.' }) { + const release = await resolvePupRelease({ + version: validatePupVersion(env.INPUT_PUP_VERSION), + token: env.INPUT_TOKEN || '', + fetchImpl, + what, + }) + + // 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, core }) + + 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, + pharPath, + pharExists, + downloadPhar, + pupRunner, + setupPup, + lastLine, +} diff --git a/.github/lib/workspace.js b/.github/lib/workspace.js new file mode 100644 index 0000000..eb78a3a --- /dev/null +++ b/.github/lib/workspace.js @@ -0,0 +1,74 @@ +'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. + * + * 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 or absolute. + * + * @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 (${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}` + ) + } + + return dir +} + +/** + * 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 } 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. diff --git a/templates/workflows/zip.yml b/templates/workflows/zip.yml new file mode 100644 index 0000000..e714ee5 --- /dev/null +++ b/templates/workflows/zip.yml @@ -0,0 +1,238 @@ +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 + +# 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 + # 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. 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: + 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.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 + 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 + + - 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 }} + # 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). 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: + # 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' + 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 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'], ]) }) }) 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..3c096f7 --- /dev/null +++ b/tests/actions/release-zip/release-zip.test.js @@ -0,0 +1,434 @@ +'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, 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. */ +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_AFTER_BUILD: '', + 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('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 }), [ + ['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('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()) + 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 \(.*\/missing\).*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/], + [{ 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'))) + }) + }) +}) 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..778c478 --- /dev/null +++ b/tests/actions/zip-name/zip-name.test.js @@ -0,0 +1,586 @@ +'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', + 'pup-release': '2.0.0', + 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', + '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', + }) + }) + + 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', + 'pup-release': '2.0.0', + 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', + 'pup-release': '2.0.0', + 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', + 'pup-release': '2.0.0', + version: '4.19.0.1', + 'zip-name': 'give.4.19.0.1', + prerelease: '', + }) + } + }) + + 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', + 'pup-release': '2.0.0', + 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}`) + 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) => { + // 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 \(.*\/source\).*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()) + 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. + 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([