diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md new file mode 100644 index 0000000..fe8e595 --- /dev/null +++ b/.github/actions/release-prep/README.md @@ -0,0 +1,220 @@ +# release-prep + +Prepares a release branch: bumps the version, replaces the `@since TBD` placeholders, writes the +changelog, then commits and pushes the result to the branch: + +```yaml +- uses: actions/checkout@v6 + with: + ref: ${{ inputs.branch }} + persist-credentials: false + +- uses: stellarwp/plugin-toolbox/.github/actions/release-prep@v1 + with: + branch: ${{ inputs.branch }} + version: ${{ inputs.version }} +``` + +The action opens no pull request; the commit is reviewed on the release branch's own pull request. +[`templates/workflows/release-prep.yml`](../../../templates/workflows/release-prep.yml) is a complete +workflow around it, ready to copy into a plugin repository. + +## What it changes + +Three tools run, in this order, from the root of the checkout: + +1. `pup replace-version ` rewrites the version in every file `.puprc` lists under + `paths.versions`. +2. `pup replace-tbd ` replaces the TBD placeholders (`@since TBD`, `@deprecated TBD`, + `_deprecated_function( __METHOD__, 'TBD' )` and the like) in the directories `.puprc` lists under + `checks.tbd.dirs`. +3. The [changelogger action](https://github.com/stellarwp/changelogger) writes the pending entries in + `changelog/` into the changelog files and deletes them, with the version and the date. + +Everything the working tree changed is then committed as `Prepare ()` by +`github-actions[bot]` and pushed to `branch`. Run the action on a clean checkout, and put any step +that writes tracked files after it: the commit includes every change in the working tree. + +pup is downloaded from its GitHub release into the runner's temp directory, so it never ends up in +the commit. See [Which pup runs](#which-pup-runs). + +## What the repository needs + +A `.puprc` listing the version files and the directories that hold the TBD placeholders: + +```json +{ + "paths": { + "versions": [ + { "file": "my-plugin.php", "regex": "(Version: )(.+)" }, + { "file": "my-plugin.php", "regex": "(define\\( 'MY_PLUGIN_VERSION', ')([^']+)" }, + { "file": "readme.txt", "regex": "(Stable tag: )(.+)" } + ] + }, + "checks": { + "tbd": { + "dirs": ["src", "includes"] + } + } +} +``` + +A `changelogger` section in `package.json`, and the pending entries in its `changesDir`: + +```json +{ + "changelogger": { + "versioning": "stellarwp", + "changesDir": "changelog", + "files": [{ "path": "readme.txt", "strategy": "stellarwp-readme" }] + } +} +``` + +The action checks the `.puprc` before running anything, because pup does not: a missing file, one +that is not JSON, or one with no `paths.versions` makes pup change nothing and exit 0. The job +fails on any of the three, with the branch untouched. + +Two gaps only produce a warning; the run continues: + +- No `checks.tbd.dirs`: pup falls back to its default and replaces TBDs under `src/` only. +- No `changelogger` section: the changelogger uses its defaults — a keepachangelog `changelog.md` + with semver versioning. + +A writing strategy given as a `.js` file is loaded from the checkout; it has to be there before the +action runs. One written in TypeScript has to be built in an earlier step. + +## Versions it refuses + +The version has to be three or four numeric parts, such as `4.17.0` or `4.17.0.1`. That is the shape +the changelogger's default `stellarwp` versioning accepts. A repository with a custom changelogger +version strategy may accept other shapes, but this action still takes three or four numeric parts. +The changelog step would fail on anything else, so the action refuses it before pup has touched +anything. + +- **An empty version is refused.** A tool that dispatches the workflow for a release with no version + set sends an empty one. When the workflow declares `version` as required, as the template does, + GitHub refuses the dispatch itself with HTTP 422 and no run starts. When the workflow declares it + optional, the run starts and the action fails it with "No version was given". Either way the + release needs a version before the step runs again. +- **A pre-release is refused.** `4.17.0-beta.1` is rejected by the `stellarwp` versioning, so the + action cannot prepare one, even though it is a valid tag name. A custom version strategy that + accepts pre-releases does not change this. +- **A version lower than the branch's is refused.** pup reads the version the branch already has + (`pup get-version`, the first file in `paths.versions`), and preparing `4.17.0` on a branch at + `4.18.0` would bump the plugin backwards: either the version or the branch is wrong. The same + version is accepted; that is what re-running a preparation finds. When the version files hold + something that is not a numeric version, such as `dev`, the job only warns. When pup cannot find a + version at all, it fails, as `replace-version` would. + +Each refusal happens before anything is changed. So do a `date` that is not `YYYY-MM-DD`, a `branch` +that is not a valid branch name, and a checkout that is not on `branch`. + +## Inputs + +| Input | Required | Default | What it does | +|---|---|---|---| +| `branch` | yes | | The release branch to push to, e.g. `release/M26.jigglypuff`. A branch name, not `refs/heads/…`. The checkout has to be on it; see [Pushing](#pushing) | +| `version` | yes | | The version to prepare, e.g. `4.17.0`. See [Versions it refuses](#versions-it-refuses) | +| `date` | no | today, in UTC | The changelog date as `YYYY-MM-DD`. Empty or `today` writes today | +| `token` | no | `github.token` | The token the commit is pushed with. See [Pushing](#pushing) | +| `pup-version` | no | `latest` | The pup release to run: a number such as `2.0.0`, or `latest` for the newest. See [Which pup runs](#which-pup-runs) | + +## Outputs + +| Output | Example | What it is | +|---|---|---| +| `version` | `4.17.0` | The version prepared | +| `date` | `2026-10-08` | The date written in the changelog | +| `commit` | `3f2a…` | The commit pushed to the branch. Empty when nothing changed | +| `files` | `my-plugin.php` | The files the commit changed, one per line. Empty when nothing changed | + +## 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 which kind of pup runs, not a version comparison: the action sends a +HEAD request to the release's `pup.phar` asset. Releases up to and including 2.0.0 publish one; the +phar downloads into the runner's temp directory and runs on the runner's PHP. A release that +answers 404 is the TypeScript line, which is meant to run through its own GitHub Action — and the +prepare step fails there, saying so and naming the pin to use instead. The manifest cannot carry +that call behind a condition: a composite action's `uses:` references resolve when the job is set +up, not when the step runs, so a conditional reference to an action that does not exist yet would +break every run. When the TypeScript line publishes its action, the call is wired in as the +changelogger's is — one `uses:` step per pup command, `command:` as the input: + +```yaml +- uses: stellarwp/pup@v3 + with: + command: replace-version --version=4.17.0 +``` + +`uses:` takes a static ref, so the floating major tag is the moving pin. The branch-version check +— the refusal of a version lower than the branch already has — runs on the phar path only; the TS +contract does not define it yet. + +## Running it again + +Preparing a version the branch already holds changes nothing: the versions match, no TBDs are left +and `changelog/` is empty. The action commits and pushes nothing and succeeds, so a retry of a +finished preparation is safe. + +## The date + +The changelogger action writes the date exactly as it is given: `today` would land in the changelog +as the word, and an empty value as an empty date. The action resolves both to today's date, in UTC, +before the changelog step runs, and that is the date the `date` output reports. + +## Pushing + +The job needs `contents: write`: + +```yaml +jobs: + prepare: + permissions: + contents: write +``` + +The action pushes with `token` itself, to `HEAD:refs/heads/`, so the checkout can use +`persist-credentials: false`. The token reaches git through the environment of that one push — +never on its command line, which the log would echo, and never in `.git/config`, where a later +step could read it. The token and the base64 credential built from it are masked in the log. + +When the checkout keeps its own credentials, git would send two Authorization headers — its own +and the action's — and GitHub refuses the push with a 400. The action clears the +`http./.extraheader` key before adding its own header, so only `token` is sent. + +The push is a fast-forward. If the branch moved after the checkout, the push is refused and the +job fails with git's output. + +The checkout has to be on `branch` itself, and the action checks that before changing anything. +Pushing from another branch would also fast-forward whenever the release branch is an ancestor of +it — for instance a run started from `main` whose checkout was given no `ref` — and the release +would pick up every commit of that branch. A detached checkout is refused for the same reason. + +Pass a PAT or app token as `token` when a ruleset on the release branches blocks `github.token`, +or when a workflow in the repository has to run on the push. GitHub creates no workflow run for a +push made with `GITHUB_TOKEN`. + +## How it runs + +Two [actions/github-script](https://github.com/actions/github-script) steps run `release-prep.js`, +with the changelogger action between them. The first validates the inputs and the `.puprc`, then +runs pup. The second commits and pushes. github-script supplies the Node runtime and `exec`; there +is no dependency to install and no bundle to build. + +The script is loaded by path rather than inlined in the manifest because github-script's `require` +resolves against the workspace of the repository being built, not this action's directory. The +path comes from `${{ github.action_path }}`. + +Inputs reach the script through `process.env`, never by being interpolated into it, so a version +or a branch name is data and cannot become part of the program. The commands run with their +arguments as a list, without a shell. + +`tests/actions/release-prep/release-prep.test.js` covers the rules above. The pup steps run +against a stand-in for `exec`; the commit and push run real git against a local repository. Run +`npm install` once, then `node --test`. diff --git a/.github/actions/release-prep/action.yml b/.github/actions/release-prep/action.yml new file mode 100644 index 0000000..1f5653f --- /dev/null +++ b/.github/actions/release-prep/action.yml @@ -0,0 +1,106 @@ +# Prepares a release branch: bumps the version, replaces the TBD placeholders, writes the changelog, +# then commits that and pushes it to the branch. It opens no pull request, so the change is reviewed +# wherever the release itself is reviewed. +# +# The checks and the git work live in release-prep.js, run by actions/github-script, so there is no +# dependency to install and nothing to build. The changelog is written by the changelogger's own +# action between the two script steps, rather than reimplemented here. +name: Release prep +description: Bumps the version, replaces TBDs and writes the changelog, then pushes it to the release branch + +inputs: + branch: + description: >- + The release branch to push the preparation to, e.g. `release/M26.jigglypuff`. A branch name, not + any git ref. The action does not check it out: the caller's checkout has to be on it, and the + action refuses to run when it is not. + required: true + version: + description: >- + The version to prepare: three or four numeric parts, such as `4.17.0` or `4.17.0.1`. That is + the shape the default `stellarwp` changelogger versioning takes; a custom version strategy + does not widen what the action accepts. An empty value and a version lower than the one the + branch already has are refused before anything is changed. + required: true + date: + description: >- + The release date for the changelog, as `YYYY-MM-DD`. Empty or `today` writes today's date in + UTC. + required: false + default: '' + token: + description: >- + Token the commit is pushed with. It needs `contents: write` on the repository, so the job needs + that permission too. The action pushes with it explicitly, so the checkout can use + `persist-credentials: false`. + required: false + default: ${{ github.token }} + pup-version: + description: >- + The pup release that bumps the version and replaces the TBDs: a release number, such as + 2.0.0, or 'latest' for the newest release. Versions up to 2.0.0 ship as a .phar; once pup + publishes a release without one, the action runs that release's own GitHub Action instead. + required: false + default: 'latest' + +outputs: + version: + description: 'The version prepared.' + value: ${{ steps.prepare.outputs.version }} + date: + description: 'The date written in the changelog, as `YYYY-MM-DD`.' + value: ${{ steps.prepare.outputs.date }} + commit: + description: 'The commit pushed to the branch. Empty when nothing changed and nothing was pushed.' + value: ${{ steps.commit.outputs.commit }} + files: + description: 'The files the commit changed, one per line. Empty when nothing changed.' + value: ${{ steps.commit.outputs.files }} + +runs: + using: 'composite' + steps: + - name: Check the inputs, bump the version and replace the TBDs + id: prepare + uses: actions/github-script@v9 + env: + # github-script's require() resolves against the workspace of the repository being built, + # not this action's directory, so the path has to be passed in. github.action_path is the + # documented expression for it. + ACTION_PATH: ${{ github.action_path }} + # Read from process.env rather than interpolated into the script, so an input is data and + # never part of the program. + INPUT_BRANCH: ${{ inputs.branch }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_DATE: ${{ inputs.date }} + INPUT_PUP_VERSION: ${{ inputs.pup-version }} + # Authenticates the 'latest' lookup against the GitHub API; a pinned pup-version + # never calls the API. + INPUT_TOKEN: ${{ inputs.token }} + with: + script: | + const { runPrepare } = require(`${process.env.ACTION_PATH}/release-prep.js`) + await runPrepare({ core, exec, env: process.env }) + + # The resolved date, never inputs.date: this action writes the date it is given as it is, so + # `today` would land in the changelog as the word and an empty value as an empty date. + - name: Write the changelog + uses: stellarwp/changelogger@v1 + with: + command: write + version: ${{ steps.prepare.outputs.version }} + date: ${{ steps.prepare.outputs.date }} + + - name: Commit the preparation and push it to the release branch + id: commit + uses: actions/github-script@v9 + env: + ACTION_PATH: ${{ github.action_path }} + # The values the prepare step validated, not the raw inputs. + INPUT_BRANCH: ${{ steps.prepare.outputs.branch }} + INPUT_VERSION: ${{ steps.prepare.outputs.version }} + INPUT_TOKEN: ${{ inputs.token }} + with: + script: | + const { runCommit } = require(`${process.env.ACTION_PATH}/release-prep.js`) + await runCommit({ core, exec, env: process.env }) diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js new file mode 100644 index 0000000..b8701cf --- /dev/null +++ b/.github/actions/release-prep/release-prep.js @@ -0,0 +1,724 @@ +'use strict' + +/** + * Prepares a release branch: bumps the version, replaces the TBD placeholders, and commits and + * pushes what the changelog step wrote alongside them. + * + * The action runs in two github-script steps with the changelogger action between them, so there + * are two entry points. runPrepare validates everything and runs pup; runCommit commits and pushes. + * The functions they are built from are pure, apart from the reads of .puprc and package.json and + * the commands run through `exec`, so a test can pass a stand-in and assert the calls. + */ + +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +const { compareVersions } = require('../../lib/versions.js') + +/** + * Three or four numeric parts. It is what the stellarwp changelog versioning accepts, minus its + * blind spot: it reads an empty part as 0, so it would take 2.1..21. A version of this shape is + * also always a valid tag name. + */ +const VERSION_PATTERN = /^\d+\.\d+\.\d+(\.\d+)?$/ + +/** A pup release number, which goes into the download URL. */ +const PUP_VERSION_PATTERN = /^\d+\.\d+\.\d+$/ + +/** A calendar date as the changelog writing strategies expect it. */ +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ + +/** The identity of the bot GitHub shows for commits made with the default token. */ +const COMMIT_AUTHOR = { + name: 'github-actions[bot]', + email: '41898282+github-actions[bot]@users.noreply.github.com', +} + +/** + * Checks the version to prepare. + * + * @param {string} version The `version` input. + * + * @throws {Error} When it is empty, or not three or four numeric parts. + * + * @returns {string} The version, without surrounding whitespace. + */ +function validateVersion(version) { + const trimmed = String(version ?? '').trim() + + if (trimmed === '') { + throw new Error( + 'No version was given. The run was dispatched without one, which is what a release with no ' + + "version set sends: set the release's version and run this step again. Nothing was changed." + ) + } + + if (!VERSION_PATTERN.test(trimmed)) { + throw new Error( + `'${trimmed}' is not a version this action can prepare. It takes three or four numeric ` + + 'parts, such as 4.17.0 or 4.17.0.1. A pre-release such as 4.17.0-beta.1 is not supported, ' + + 'because the stellarwp changelog versioning rejects it. Nothing was changed.' + ) + } + + return trimmed +} + +/** + * Works out the date to write in the changelog. + * + * The changelogger action writes the date exactly as it is given: `today` would land in the + * changelog as the word, and an empty value as an empty date. So both are resolved here. + * + * @param {string} date The `date` input: empty, `today`, or YYYY-MM-DD. + * @param {Date} now The current time, for tests. + * + * @throws {Error} When it is anything else, or not a real calendar date. + * + * @returns {string} The date as YYYY-MM-DD, today's in UTC for an empty value or `today`. + */ +function resolveDate(date, now = new Date()) { + const trimmed = String(date ?? '').trim() + + if (trimmed === '' || trimmed.toLowerCase() === 'today') { + return now.toISOString().slice(0, 10) + } + + // A real date round-trips through Date unchanged; 2026-02-30 comes back as March 2nd. One out of + // range, such as 2026-13-45, is no date at all and would make toISOString throw, so Date.parse + // rules it out first. + const isRealDate = + DATE_PATTERN.test(trimmed) && + !Number.isNaN(Date.parse(trimmed)) && + new Date(`${trimmed}T00:00:00Z`).toISOString().slice(0, 10) === trimmed + + if (!isRealDate) { + throw new Error( + `'${trimmed}' is not a date. Pass YYYY-MM-DD, or leave it empty or 'today' for today's date ` + + 'in UTC. Nothing was changed.' + ) + } + + return trimmed +} + +/** + * Checks the shape of the branch to push to. Whether git accepts the name is checked by git, in + * runPrepare. + * + * @param {string} branch The `branch` input. + * + * @throws {Error} When it is empty or a full ref. + * + * @returns {string} The branch name, without surrounding whitespace. + */ +function validateBranch(branch) { + const trimmed = String(branch ?? '').trim() + + if (trimmed === '') { + throw new Error('No branch was given: pass the release branch to prepare, e.g. release/M26.jigglypuff.') + } + + // The push writes refs/heads/, so a full ref would create a branch named refs/heads/…. + if (trimmed.startsWith('refs/')) { + throw new Error( + `Refusing to prepare '${trimmed}': pass the branch name, e.g. ` + + `'${trimmed.replace(/^refs\/heads\//, '')}'.` + ) + } + + return trimmed +} + +/** + * Checks the pup release to download. + * + * @param {string} version The `pup-version` input. + * + * @throws {Error} When it is not a release number. + * + * @returns {string} The version. + */ +function validatePupVersion(version) { + const trimmed = String(version ?? '').trim() + + if (trimmed.toLowerCase() !== 'latest' && !PUP_VERSION_PATTERN.test(trimmed)) { + throw new Error(`pup-version '${trimmed}' is not a pup release number, such as 2.0.0, or 'latest'.`) + } + + return trimmed +} + +/** + * Resolves the `pup-version` input to a concrete pup release. + * + * A pinned release number is used as it is: the download from the release page fails on its + * own when the release does not exist, so there is no API call for the common case. `latest` + * asks the GitHub API for the newest release, authenticated with the action token when the + * caller passes one, so the runner's shared unauthenticated quota is not spent. + * + * @param {object} options + * @param {string} options.version The validated `pup-version` input. + * @param {string} options.token The action token, possibly empty. + * @param {Function} options.fetchImpl What `fetch` to use; a test passes a stand-in. + * + * @throws {Error} When the latest release cannot be read. + * + * @returns {Promise} The release tag to download from. + */ +async function resolvePupRelease({ version, token, fetchImpl }) { + if (version.toLowerCase() !== 'latest') { + return version + } + + const headers = {} + if (token) { + headers.authorization = `Bearer ${token}` + } + + const response = await fetchImpl('https://api.github.com/repos/stellarwp/pup/releases/latest', { + headers, + }) + + if (!response.ok) { + throw new Error( + `Reading the latest pup release failed (HTTP ${response.status}). Pass a pinned pup-version and run this step again. Nothing was changed.` + ) + } + + // The tag goes into a download URL and a temp file path, and a missing one would surface + // as a 404 on …/download/undefined/pup.phar — which the distribution check reads as "no + // phar, this is the TypeScript line". A broken API response must fail here, not misroute + // the run, so the tag is validated before it is used. + const tag = (await response.json()).tag_name + if (typeof tag !== 'string' || !PUP_VERSION_PATTERN.test(tag)) { + throw new Error( + 'The latest pup release has no usable tag. Pass a pinned pup-version and run this step ' + + 'again. Nothing was changed.' + ) + } + + return tag +} + +/** + * Reads what the action needs from the repository's .puprc. + * + * pup itself does not fail on a missing or broken .puprc: it reads either as empty, bumps nothing + * and exits 0. So the file is checked here, before pup runs, and a release branch is never pushed + * with the version left as it was. + * + * @param {string|null} text The contents of .puprc, or null when there is none. + * + * @throws {Error} When there is no .puprc, it is not JSON, or it declares no version files. + * + * @returns {{tbdDirs: string[]|null}} The directories the TBD check declares, or null when it + * declares none and pup falls back to its default. + */ +function readPuprc(text) { + if (text === null) { + throw new Error( + 'No .puprc in the repository. The version files to bump come from its paths.versions, so ' + + "the action needs one; see the action's README. Nothing was changed." + ) + } + + let config + try { + config = JSON.parse(text) + } catch (error) { + throw new Error( + `.puprc is not valid JSON (${error.message}). pup would read it as empty and bump nothing. ` + + 'Nothing was changed.' + ) + } + + const versions = config?.paths?.versions + if (!Array.isArray(versions) || versions.length === 0) { + throw new Error( + '.puprc has no paths.versions, so pup has no version to bump. List each file and the regex ' + + "that finds its version; see the action's README. Nothing was changed." + ) + } + + const dirs = config?.checks?.tbd?.dirs + + return { tbdDirs: Array.isArray(dirs) ? dirs : null } +} + +/** + * Whether package.json configures the changelogger. Without that section the changelogger falls + * back to its defaults, which write a keepachangelog `changelog.md` with semver versioning. + * + * @param {string|null} text The contents of package.json, or null when there is none. + * + * @returns {boolean} True when it has a `changelogger` section. + */ +function hasChangeloggerConfig(text) { + if (text === null) { + return false + } + + try { + return Boolean(JSON.parse(text)?.changelogger) + } catch { + return false + } +} + +/** + * Where a pup release's phar is published. + * + * @param {string} version A pup release number, already validated. + * + * @returns {string} The download URL. + */ +function pupDownloadUrl(version) { + return `https://github.com/stellarwp/pup/releases/download/${version}/pup.phar` +} + +/** + * Which distribution a pup release carries, decided by looking at the release itself: a `pup.phar` + * asset means the PHP build the action has always run, and its absence means the release expects + * to run through its own GitHub Action (the TypeScript line, once it publishes one). + * + * The check is a HEAD request against the asset URL, not the releases API, so a pinned + * `pup-version` stays free of API calls and rate limits. + * + * @param {object} exec The @actions/exec toolkit. + * @param {string} release The resolved pup release tag. + * + * @throws {Error} When the check itself fails; the run cannot tell which path to take. + * + * @returns {Promise<'phar'|'ts'>} The distribution the release carries. + */ +async function resolvePupDistribution({ exec, release }) { + const head = await exec.getExecOutput('curl', ['-fsSI', pupDownloadUrl(release)], { + ignoreReturnCode: true, + silent: true, + }) + + if (head.exitCode === 0) { + return 'phar' + } + + // curl -f answers a 404 with exit code 22 and the status in stderr. + if (head.exitCode === 22 && /404/.test(head.stderr)) { + return 'ts' + } + + const output = [head.stdout, head.stderr].map((text) => text.trim()).filter(Boolean) + throw new Error( + `Checking the pup ${release} release failed (exit code ${head.exitCode}):\n` + + output.join('\n') + ) +} + +/** + * The pup commands that prepare a version, in the order they run. + * + * @param {string} phar Path to the downloaded pup.phar. + * @param {string} version The version to prepare, already validated. + * + * @returns {{command: string, args: string[]}[]} The version bump, then the TBD replacement. + */ +function pupCommands(phar, version) { + return [ + { command: 'php', args: [phar, 'replace-version', version] }, + { command: 'php', args: [phar, 'replace-tbd', version] }, + ] +} + +/** + * The basic credential git sends for a token, as actions/checkout builds it. + * + * @param {string} token The token. + * + * @returns {string} base64 of `x-access-token:`. + */ +function basicCredential(token) { + return Buffer.from(`x-access-token:${token}`).toString('base64') +} + +/** + * The environment that hands git the token for one push. + * + * git reads GIT_CONFIG_COUNT and the numbered KEY/VALUE pairs as extra config for that one + * process. The token therefore never sits on a command line, which the log echoes, and is never + * written to .git/config, where a later step could read it. That is what lets a caller check out + * with `persist-credentials: false`. + * + * A checkout that does persist its credentials puts its own header under the same key, and git + * sends every value of it: two Authorization headers, which GitHub refuses with a 400. An empty + * value resets the key's list, so the first entry clears whatever the checkout left and only this + * token is sent. + * + * @param {string} token The token, or empty to leave authentication to the checkout. + * @param {string} serverUrl The GitHub server, e.g. https://github.com. + * + * @returns {object} The variables to add to git's environment; none without a token. + */ +function pushEnv(token, serverUrl) { + if (!token) { + return {} + } + + const key = `http.${serverUrl.replace(/\/+$/, '')}/.extraheader` + + return { + GIT_CONFIG_COUNT: '2', + GIT_CONFIG_KEY_0: key, + GIT_CONFIG_VALUE_0: '', + GIT_CONFIG_KEY_1: key, + GIT_CONFIG_VALUE_1: `AUTHORIZATION: basic ${basicCredential(token)}`, + } +} + +/** + * The message of the commit the action pushes. + * + * @param {string} branch The release branch. + * @param {string} version The version prepared. + * + * @returns {string} The commit message. + */ +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. + * + * @param {string} output What pup printed. + * + * @returns {string|null} The version, or null when the line holds no numeric version, such as `dev`. + */ +function readCurrentVersion(output) { + const match = /^\d+(\.\d+)*/.exec(lastLine(output)) + + return 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 + } +} + +/** + * Runs a command and fails with its output when it exits non-zero. + * + * @param {object} exec The @actions/exec toolkit. + * @param {string} command The program. + * @param {string[]} args Its arguments. + * @param {object} options Options for @actions/exec. + * @param {string} what What the command does, to open the error message. + * + * @throws {Error} When the command exits non-zero, holding what it printed. + * + * @returns {Promise} What the command printed to stdout. + */ +async function runOrFail(exec, command, args, options, what) { + const result = await exec.getExecOutput(command, args, { ...options, ignoreReturnCode: true }) + + if (result.exitCode !== 0) { + const output = [result.stdout, result.stderr].map((text) => text.trim()).filter(Boolean) + + throw new Error(`${what} failed (exit code ${result.exitCode}):\n${output.join('\n')}`) + } + + return result.stdout +} + +/** + * Validates the inputs, then bumps the version and replaces the TBDs with pup. + * + * Every check runs before anything is written, so a wrong version, date or ref, a checkout that is + * not on the release branch, a repository without a usable .puprc, or a version lower than the one + * the branch already has, fails with the branch untouched. + * + * @param {object} core The @actions/core toolkit, for the log and the outputs. + * @param {object} exec The @actions/exec toolkit. + * @param {object} env The environment the action manifest put its inputs in. + * @param {string} cwd The checkout, the working directory by default. + * @param {Function} fetchImpl What `fetch` to use; a test passes a stand-in. + * + * @throws {Error} When an input is wrong, .puprc is unusable, or the download or pup fails. + * + * @returns {Promise} Resolves once pup has run and the outputs are set. + */ +async function runPrepare({ core, exec, env, cwd = process.cwd(), fetchImpl = globalThis.fetch }) { + const version = validateVersion(env.INPUT_VERSION) + const date = resolveDate(env.INPUT_DATE) + const branch = validateBranch(env.INPUT_BRANCH) + const pupVersion = validatePupVersion(env.INPUT_PUP_VERSION) + + const refCheck = await exec.getExecOutput('git', ['check-ref-format', `refs/heads/${branch}`], { + cwd, + ignoreReturnCode: true, + silent: true, + }) + if (refCheck.exitCode !== 0) { + throw new Error(`'${branch}' is not a valid branch name. Nothing was changed.`) + } + + // The push writes HEAD to refs/heads/. From any other branch that the release branch is an + // ancestor of, git would accept it as a fast-forward and carry that branch's commits into the + // release, so the checkout has to be on the release branch itself. + const head = await exec.getExecOutput('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], { + cwd, + ignoreReturnCode: true, + silent: true, + }) + const headBranch = head.exitCode === 0 ? head.stdout.trim() : '' + if (headBranch !== branch) { + throw new Error( + `The checkout is on ${headBranch ? `'${headBranch}'` : 'a detached commit'}, not on '${branch}'. ` + + 'Check out the release branch before this action, e.g. actions/checkout with `ref`. Nothing ' + + 'was changed.' + ) + } + + const { tbdDirs } = readPuprc(readCheckoutFile(cwd, '.puprc')) + + if (!tbdDirs) { + core.warning( + '.puprc declares no checks.tbd.dirs, so pup replaces TBDs under src/ only, its default. ' + + 'Declare the directories that hold @since TBD tags to cover the rest.' + ) + } + + if (!hasChangeloggerConfig(readCheckoutFile(cwd, 'package.json'))) { + core.warning( + 'package.json has no changelogger section, so the changelog step uses the changelogger ' + + 'defaults: a keepachangelog changelog.md with semver versioning.' + ) + } + + const pupRelease = await resolvePupRelease({ + version: pupVersion, + token: env.INPUT_TOKEN || '', + fetchImpl, + }) + const distribution = await resolvePupDistribution({ exec, release: pupRelease }) + core.setOutput('distribution', distribution) + + if (distribution === 'phar') { + await runPrepareWithPhar({ core, exec, cwd, env, pharRelease: pupRelease, version, branch }) + } else { + // The TypeScript line publishes no phar and would run through its own GitHub Action — but a + // composite action's `uses:` references resolve when the JOB is set up, not when the step + // runs, so the manifest cannot carry those calls behind a condition: they would break every + // run until the pup action exists. Until it does, a phar-less release fails here, loudly, + // with the way out. The branch-version check below is part of the phar path — the TS + // contract does not define it yet. + throw new Error( + `pup ${pupRelease} publishes no .phar asset, and the TypeScript distribution is not ` + + 'callable from this action yet. Pin pup-version to a release that ships a .phar, such ' + + 'as 2.0.0. Nothing was changed.' + ) + } + + core.setOutput('version', version) + core.setOutput('date', date) + core.setOutput('branch', branch) +} + +/** + * Runs the preparation with the phar a pup release publishes: downloads it, checks the version + * the branch already has, then bumps the version and replaces the TBDs. + * + * @param {object} options + * @param {object} options.core The @actions/core toolkit. + * @param {object} options.exec The @actions/exec toolkit. + * @param {string} options.cwd The checkout. + * @param {object} options.env The step environment, for RUNNER_TEMP. + * @param {string} options.pharRelease The resolved pup release tag. + * @param {string} options.version The version to prepare, validated. + * @param {string} options.branch The release branch, validated. + * + * @returns {Promise} Resolves once pup has run. + */ +async function runPrepareWithPhar({ core, exec, cwd, env, pharRelease, version, branch }) { + const phar = path.join(env.RUNNER_TEMP || os.tmpdir(), `pup-${pharRelease}.phar`) + + await runOrFail( + exec, + 'curl', + ['-fsSL', '--retry', '3', '-o', phar, pupDownloadUrl(pharRelease)], + {}, + `Downloading pup ${pharRelease}` + ) + + // 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 current = readCurrentVersion(printed) + + if (current === null) { + core.warning( + `The version files hold \`${lastLine(printed)}\`, which is not a numeric version, so ` + + `${version} was not checked against it.` + ) + } else if (compareVersions(version, current) < 0) { + throw new Error( + `Refusing to prepare ${version}: ${branch} already has ${current}, a later version. Check the ` + + 'version and the release branch. Nothing was changed.' + ) + } + + for (const { command, args } of pupCommands(phar, version)) { + await runOrFail(exec, command, args, { cwd }, `pup ${args[1]}`) + } +} + +/** + * Commits whatever the preparation changed and pushes it to the release branch. + * + * When nothing changed, which is what re-preparing a version that is already on the branch finds, + * it neither commits nor pushes, and succeeds. + * + * @param {object} core The @actions/core toolkit, for the log, the outputs and the summary. + * @param {object} exec The @actions/exec toolkit. + * @param {object} env The environment the action manifest put its inputs in. + * @param {string} cwd The checkout, the working directory by default. + * + * @throws {Error} When a git command fails, the push included. + * + * @returns {Promise} Resolves once the commit is pushed, or found unnecessary. + */ +async function runCommit({ core, exec, env, cwd = process.cwd() }) { + const branch = validateBranch(env.INPUT_BRANCH) + const version = validateVersion(env.INPUT_VERSION) + const message = commitMessage(branch, version) + const token = env.INPUT_TOKEN || '' + + if (token) { + core.setSecret(token) + core.setSecret(basicCredential(token)) + } + + await runOrFail(exec, 'git', ['add', '--all'], { cwd }, 'Staging the changes') + + const staged = await runOrFail( + exec, + 'git', + ['diff', '--cached', '--name-only'], + { cwd, silent: true }, + 'Listing the changes' + ) + const files = staged.split('\n').filter(Boolean) + + if (files.length === 0) { + core.info(`Nothing changed: ${branch} already holds ${version}. Nothing was committed or pushed.`) + core.setOutput('commit', '') + core.setOutput('files', '') + await core.summary + .addRaw(`Nothing to commit: \`${branch}\` already holds ${version}.`, true) + .write() + + return + } + + await runOrFail( + exec, + 'git', + [ + '-c', + `user.name=${COMMIT_AUTHOR.name}`, + '-c', + `user.email=${COMMIT_AUTHOR.email}`, + 'commit', + '--quiet', + '-m', + message, + ], + { cwd }, + 'Committing the changes' + ) + + const commit = ( + await runOrFail(exec, 'git', ['rev-parse', 'HEAD'], { cwd, silent: true }, 'Reading the commit') + ).trim() + + await runOrFail( + exec, + 'git', + ['push', 'origin', `HEAD:refs/heads/${branch}`], + { cwd, env: { ...env, ...pushEnv(token, env.GITHUB_SERVER_URL || 'https://github.com') } }, + `Pushing to ${branch}` + ) + + core.info(`Pushed ${commit} to ${branch}.`) + core.setOutput('commit', commit) + core.setOutput('files', files.join('\n')) + // A Markdown list rather than addList, whose HTML list would show the backticks literally. + await core.summary + .addRaw(`Pushed \`${commit.slice(0, 7)}\` "${message}" to \`${branch}\`:\n\n`) + .addRaw(files.map((file) => `- \`${file}\``).join('\n'), true) + .write() +} + +module.exports = { + runPrepare, + runCommit, + validateVersion, + resolveDate, + validateBranch, + validatePupVersion, + resolvePupRelease, + resolvePupDistribution, + readPuprc, + hasChangeloggerConfig, + pupDownloadUrl, + pupCommands, + basicCredential, + pushEnv, + commitMessage, + compareVersions, + readCurrentVersion, + VERSION_PATTERN, +} diff --git a/.github/actions/resolve-floating-tags/resolve.js b/.github/actions/resolve-floating-tags/resolve.js index 018140e..02f98c3 100644 --- a/.github/actions/resolve-floating-tags/resolve.js +++ b/.github/actions/resolve-floating-tags/resolve.js @@ -10,6 +10,7 @@ /** A version is numbers separated by dots, at least two of them. */ const NUMERIC_VERSION = /^\d+(\.\d+)+$/ +const { toParts, compareVersions } = require('../../lib/versions.js') /** How many leading parts of the version each level's tag name carries. */ const LEVEL_DEPTHS = { major: 1, minor: 2 } @@ -27,51 +28,6 @@ function toVersion(tagName) { return tagName.replace(/^v/, '').replace(/\+.*$/, '') } -/** - * Splits a version into its numbers, for comparing one part against another. - * - * @param {string} version A numeric version, e.g. `1.2.3`. - * - * @returns {number[]} Its parts, e.g. `[1, 2, 3]`. - */ -function toParts(version) { - return version.split('.').map(Number) -} - -/** - * Orders two versions by their numbers. 1.10.0 is above 1.9.0, and 1.2.3.1 above 1.2.3, neither of - * which a string comparison gets right. Missing trailing parts count as zero, so 1.2.3 and 1.2.3.0 - * are the same version. - * - * The semver package cannot express a version with four parts: 1.2.3.4 is invalid with or without - * its `loose` option, and comparing one throws. `coerce` drops the fourth part, so a 1.2.3.1 hotfix - * would compare equal to the 1.2.3 it patches and v1.2 would stop moving to a hotfix. A dependency - * would also have to be installed by every repository calling this action, since github-script - * resolves one from the calling repository's directory. - * - * semver does have prerelease precedence, ordering 1.4.0-rc.1 below 1.4.0, and this does not. No - * suffixed version reaches this function: releasedVersions keeps only numeric versions, and `core` - * has any suffix stripped. See Prereleases in the action's README. - * - * @param {string} a A numeric version. - * @param {string} b The version to compare it against. - * - * @returns {number} -1 when `a` is lower, 1 when it is higher, 0 when they are the same version. - */ -function compareVersions(a, b) { - const left = toParts(a) - const right = toParts(b) - - for (let i = 0; i < Math.max(left.length, right.length); i++) { - const difference = (left[i] ?? 0) - (right[i] ?? 0) - if (difference !== 0) { - return difference < 0 ? -1 : 1 - } - } - - return 0 -} - /** * Whether a version belongs to the line a floating tag covers. The line is named by its leading * parts, and a member has to carry at least one part beyond them: 1.2.3 and 1.2.3.1 are both in the diff --git a/.github/lib/versions.js b/.github/lib/versions.js new file mode 100644 index 0000000..d9f840e --- /dev/null +++ b/.github/lib/versions.js @@ -0,0 +1,57 @@ +'use strict' + +/** + * Numeric version parts, shared by the toolbox actions. + * + * One implementation lives here so every action orders versions the same way. The actions load + * this file through a path relative to their own directory, so it ships inside the same + * repository checkout the runner already fetches for the action — there is nothing to publish + * and nothing for a caller to install. + */ + +/** + * Splits a version into its numbers, for comparing one part against another. + * + * @param {string} version A numeric version, e.g. `1.2.3`. + * + * @returns {number[]} Its parts, e.g. `[1, 2, 3]`. + */ +function toParts(version) { + return version.split('.').map(Number) +} + +/** + * Orders two versions by their numbers. 1.10.0 is above 1.9.0, and 1.2.3.1 above 1.2.3, neither of + * which a string comparison gets right. Missing trailing parts count as zero, so 1.2.3 and 1.2.3.0 + * are the same version. + * + * The semver package cannot express a version with four parts: 1.2.3.4 is invalid with or without + * its `loose` option, and comparing one throws. `coerce` drops the fourth part, so a 1.2.3.1 hotfix + * would compare equal to the 1.2.3 it patches and v1.2 would stop moving to a hotfix. A dependency + * would also have to be installed by every repository calling an action that uses this, since + * github-script resolves one from the calling repository's directory. + * + * semver does have prerelease precedence, ordering 1.4.0-rc.1 below 1.4.0, and this does not. No + * suffixed version reaches this function: the actions keep only numeric versions by the time they + * compare. + * + * @param {string} a A numeric version. + * @param {string} b The version to compare it against. + * + * @returns {number} -1 when `a` is lower, 1 when it is higher, 0 when they are the same version. + */ +function compareVersions(a, b) { + const left = toParts(a) + const right = toParts(b) + + for (let i = 0; i < Math.max(left.length, right.length); i++) { + const difference = (left[i] ?? 0) - (right[i] ?? 0) + if (difference !== 0) { + return difference < 0 ? -1 : 1 + } + } + + return 0 +} + +module.exports = { toParts, compareVersions } diff --git a/README.md b/README.md index 972aa15..a7f2491 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,7 @@ Each phase ends with something people can actually use. - [ ] Port `setup-slic`, `prepare-matrix`, `setup-bun` from LearnDash. - [ ] Port `check-php-changes`, `smart-checkout`, `process-changelog`, `add-changelog`, `generate-pot` from TEC. - [ ] Add `setup-node` for the npm crowd. +- [x] Add `release-prep`: version bump, TBD replacement and changelog through pup and the changelogger action, committed and pushed to the release branch. A caller to copy is in `templates/workflows/release-prep.yml`. - [ ] Turn every hard-coded repo name, slug, or branch pattern into an input. - [ ] Self-checks: actionlint everything, unit test anything with real logic. - [ ] Tag `v1.0.0`. @@ -170,7 +171,7 @@ Each phase ends with something people can actually use. - [ ] `tests.yml`: slic suites via `prepare-matrix`, with LD's cheap/expensive split. Review TEC's paused [#51](https://github.com/the-events-calendar/actions/pull/51) first so that work isn't lost. - [ ] `changelog.yml`: the changelogger check. - [ ] `zip.yml`: reconcile TEC's and LD's versions. -- [ ] `release-prep.yml`: version bump, changelog, tested-up-to. +- [ ] `release-prep.yml`: a `workflow_call` wrapper for the `release-prep` action, plus the tested-up-to bump it does not do yet. - [ ] `release-tags.yml`: a `workflow_call` wrapper for the floating tag actions, so a repo gets a `uses:` instead of copying the workflow. The actions have to be referenced by full path, not `./`, because a relative path in a called workflow resolves against the caller. - [ ] Document each in `docs/continuous-integration.md` with its local command. diff --git a/package.json b/package.json index 7527e5b..92b7070 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ }, "devDependencies": { "@actions/core": "^3.0.1", + "@actions/exec": "^3.0.0", "@actions/github": "^9.1.1" } } diff --git a/templates/workflows/release-prep.yml b/templates/workflows/release-prep.yml new file mode 100644 index 0000000..a77476a --- /dev/null +++ b/templates/workflows/release-prep.yml @@ -0,0 +1,63 @@ +name: Release Prep + +# Prepares a release branch: bumps the version, replaces the @since TBD tags and writes the +# changelog, then commits that and pushes it to the branch. No pull request is opened, so the +# change is reviewed wherever the release is. +# +# A file to copy into .github/workflows/ of a plugin repository, not a workflow to call. The +# repository needs a .puprc and a changelogger config first; see the release-prep action's README. +# +# The inputs are the interface a release tool dispatches: `branch` and `version`, and an +# optional `date` for runs started by hand. Keep `date` optional: a tool that sends only `branch` +# and `version` refuses a workflow that requires more. +# +# Run it locally: you cannot, it pushes to a branch. Start it by hand with +# `gh workflow run release-prep.yml -f branch=release/M26.jigglypuff -f version=4.17.0`. + +on: + workflow_dispatch: + inputs: + branch: + description: 'Release branch to prepare and push to, e.g. release/M26.jigglypuff' + required: true + type: string + version: + description: 'Version to write, e.g. 4.17.0' + required: true + type: string + date: + description: 'Changelog date (YYYY-MM-DD). Empty or "today" writes today, in UTC' + required: false + type: string + default: '' + +# Two preparations of the same branch would both commit on top of the same checkout, and the second +# push would be refused. One group per branch makes the second wait for the first. +concurrency: + group: ${{ github.workflow }}-${{ inputs.branch }} + +permissions: + contents: read + +jobs: + prepare: + # pup runs on PHP, which ubuntu-24.04 ships. Pinned so that stays true. + runs-on: ubuntu-24.04 + permissions: + contents: write # the action pushes the preparation to the branch + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.branch }} + # The action pushes with the token it is given, so the checkout keeps no credentials in + # .git/config for a later step to find. + persist-credentials: false + + # A changelog writing strategy that has to be built first, or a toolchain the repository needs, + # is set up here, before the action. + + - uses: stellarwp/plugin-toolbox/.github/actions/release-prep@v1 + with: + branch: ${{ inputs.branch }} + version: ${{ inputs.version }} + date: ${{ inputs.date }} diff --git a/tests/actions/release-prep/release-prep.test.js b/tests/actions/release-prep/release-prep.test.js new file mode 100644 index 0000000..8c79d17 --- /dev/null +++ b/tests/actions/release-prep/release-prep.test.js @@ -0,0 +1,876 @@ +'use strict' + +const { describe, it } = require('node:test') +const assert = require('node:assert') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +const { + runPrepare, + runCommit, + validateVersion, + resolveDate, + validateBranch, + validatePupVersion, + readPuprc, + hasChangeloggerConfig, + pupDownloadUrl, + pupCommands, + basicCredential, + pushEnv, + commitMessage, + compareVersions, + readCurrentVersion, +} = require('../../../.github/actions/release-prep/release-prep.js') +const { coreWithOutputs, recordingExec } = require('../../support/actions.js') + +/** A .puprc that bumps the plugin header and replaces TBDs under src/. */ +const PUPRC = { + paths: { versions: [{ file: 'plugin.php', regex: '(Version: )(.+)' }] }, + checks: { tbd: { dirs: ['src'] } }, +} + +/** A package.json with a changelogger config, as a repository using the stellarwp format has. */ +const PACKAGE_JSON = { changelogger: { versioning: 'stellarwp', changesDir: 'changelog' } } + +/** Today in UTC, the way the action resolves an empty or `today` date. */ +const TODAY = new Date().toISOString().slice(0, 10) + +/** + * A temporary directory holding the given files, standing in for the checked-out repository. + * + * @param {object} files Each relative path mapped to its contents. An object is written as JSON. + * + * @returns {string} The directory. + */ +function checkoutWith(files = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'release-prep-')) + + for (const [name, contents] of Object.entries(files)) { + const text = typeof contents === 'string' ? contents : JSON.stringify(contents) + fs.writeFileSync(path.join(dir, name), text) + } + + return dir +} + +/** + * The environment the manifest hands the prepare step, over a valid set of inputs. + * + * @param {object} overrides Inputs to change. + * + * @returns {object} The environment. + */ +function prepareEnv(overrides = {}) { + return { + INPUT_BRANCH: 'release/M26.jigglypuff', + INPUT_VERSION: '4.17.0', + INPUT_DATE: '', + INPUT_PUP_VERSION: '2.0.0', + RUNNER_TEMP: '/runner/temp', + ...overrides, + } +} + +/** + * Rules under which every command the prepare step runs succeeds. + * + * @param {object[]} before Rules that take precedence, to make one command fail. + * + * @returns {object[]} Rules for recordingExec. + */ +function allSucceed(before = []) { + return [ + ...before, + { command: 'git', args: /^check-ref-format / }, + { command: 'git', args: /^symbolic-ref /, stdout: 'release/M26.jigglypuff\n' }, + { command: 'curl' }, + { command: 'php', args: /get-version$/, stdout: '4.16.0\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(' ')) +} + +/** + * Runs git in a directory and returns its trimmed output. + * + * @param {string} dir The directory to run in. + * @param {string[]} args The git arguments. + * + * @returns {string} What git printed. + */ +function git(dir, ...args) { + const { execFileSync } = require('node:child_process') + + return execFileSync('git', args, { cwd: dir, encoding: 'utf8' }).trim() +} + +/** + * A working copy of a repository whose `origin` is a local bare repository holding `branch`. + * + * The commit step then runs real git against it: a push goes to the bare repository, so a test can + * read back what landed on the branch. + * + * @param {string} branch The release branch the working copy has checked out. + * + * @returns {{work: string, remote: string}} The two directories. + */ +function repositoryOn(branch) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'release-prep-git-')) + const remote = path.join(root, 'remote.git') + const work = path.join(root, 'work') + + git(root, 'init', '--quiet', '--bare', remote) + git(root, 'init', '--quiet', '-b', branch, work) + git(work, 'config', 'user.name', 'Test') + git(work, 'config', 'user.email', 'test@example.com') + fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.16.0\n') + git(work, 'add', '-A') + git(work, 'commit', '--quiet', '-m', 'Initial') + git(work, 'remote', 'add', 'origin', remote) + git(work, 'push', '--quiet', 'origin', `HEAD:refs/heads/${branch}`) + + return { work, remote } +} + +/** + * The real @actions/exec, wrapped to record each call and what it printed. + * + * What a command prints is sent to a buffer of its own rather than to stdout, where the test runner + * reports results: that covers the `[command]` line @actions/exec echoes, so a test can assert what + * a run would have shown in the log. + * + * @returns {Promise<{exec: object, calls: object[], printed: Function}>} The wrapper, the calls it + * recorded, and a reader for everything the commands printed. + */ +async function recordedRealExec() { + const { Writable } = require('node:stream') + const real = await import('@actions/exec') + const calls = [] + const chunks = [] + const sink = new Writable({ + write(chunk, encoding, callback) { + chunks.push(String(chunk)) + callback() + }, + }) + + const getExecOutput = (command, args = [], options = {}) => { + calls.push({ command, args, options }) + + return real.getExecOutput(command, args, { ...options, outStream: sink, errStream: sink }) + } + + return { exec: { getExecOutput }, calls, printed: () => chunks.join('') } +} + +/** + * The environment the manifest hands the commit step. + * + * @param {object} overrides Inputs to change. + * + * @returns {object} The environment, over the real one so that git is on the PATH. + */ +function commitEnv(overrides = {}) { + return { + ...process.env, + INPUT_BRANCH: 'release/M26.jigglypuff', + INPUT_VERSION: '4.17.0', + INPUT_TOKEN: 'ghs_notarealtoken123', + GITHUB_SERVER_URL: 'https://github.com', + ...overrides, + } +} + +describe('release-prep', () => { + describe('validateVersion', () => { + it('accepts three or four numeric parts', () => { + assert.equal(validateVersion('4.17.0'), '4.17.0') + assert.equal(validateVersion('4.17.0.1'), '4.17.0.1') + }) + + it('reads the version without surrounding whitespace', () => { + assert.equal(validateVersion(' 4.17.0\n'), '4.17.0') + }) + + it('refuses an empty version, saying the run was dispatched without one', () => { + // A release dispatcher that has no version for the release sends an empty one. + for (const empty of ['', ' ', undefined]) { + assert.throws(() => validateVersion(empty), /No version was given.*Nothing was changed/s) + } + }) + + it('refuses a pre-release, which the stellarwp changelog versioning rejects', () => { + assert.throws( + () => validateVersion('4.17.0-beta.1'), + /'4\.17\.0-beta\.1' is not a version this action can prepare.*pre-release/s + ) + }) + + it('refuses anything but three or four numeric parts', () => { + // 2.1..21 matters most: the changelogger reads the empty part as 0 and would accept it. + for (const version of ['4.17', '4.17.0.1.2', '2.1..21', 'v4.17.0', '4.17.x', '4.17.0.']) { + assert.throws(() => validateVersion(version), /not a version this action can prepare/) + } + }) + }) + + describe('resolveDate', () => { + const now = new Date('2026-10-06T23:30:00-05:00') + + it('reads an empty date and `today` as today in UTC', () => { + // 23:30 in UTC-5 is already the 7th in UTC. + assert.equal(resolveDate('', now), '2026-10-07') + assert.equal(resolveDate(undefined, now), '2026-10-07') + assert.equal(resolveDate('today', now), '2026-10-07') + assert.equal(resolveDate(' Today ', now), '2026-10-07') + }) + + it('passes a YYYY-MM-DD date through', () => { + assert.equal(resolveDate('2026-10-06', now), '2026-10-06') + }) + + it('refuses anything that is not a real YYYY-MM-DD date', () => { + for (const date of ['ontem', 'yesterday', '06/10/2026', '2026-10-6', '2026-02-30', '2026-13-45']) { + assert.throws(() => resolveDate(date, now), /is not a date.*Nothing was changed/s) + } + }) + }) + + describe('validateBranch', () => { + it('accepts a branch name', () => { + assert.equal(validateBranch('release/M26.jigglypuff'), 'release/M26.jigglypuff') + }) + + it('refuses an empty ref', () => { + assert.throws(() => validateBranch(''), /No branch was given/) + }) + + it('refuses a full ref, naming the branch to pass instead', () => { + assert.throws( + () => validateBranch('refs/heads/release/M26.jigglypuff'), + /pass the branch name, e\.g\. 'release\/M26\.jigglypuff'/ + ) + }) + }) + + describe('validatePupVersion', () => { + it('accepts a release number, which goes into the download URL', () => { + assert.equal(validatePupVersion('2.0.0'), '2.0.0') + }) + + it('accepts latest', () => { + assert.equal(validatePupVersion('latest'), 'latest') + }) + + it('refuses anything else', () => { + for (const version of ['', '2.0.0/../../x', '2.0']) { + assert.throws(() => validatePupVersion(version), /pup-version/) + } + }) + }) + + describe('readPuprc', () => { + it('returns the TBD directories the file declares', () => { + assert.deepEqual(readPuprc(JSON.stringify(PUPRC)), { tbdDirs: ['src'] }) + }) + + it('returns no TBD directories when the file declares none', () => { + const { checks, ...withoutChecks } = PUPRC + + assert.deepEqual(readPuprc(JSON.stringify(withoutChecks)), { tbdDirs: null }) + }) + + it('refuses a missing file, which pup would treat as an empty one', () => { + assert.throws(() => readPuprc(null), /No \.puprc.*Nothing was changed/s) + }) + + it('refuses a file that is not JSON, which pup would also treat as empty', () => { + assert.throws(() => readPuprc('{ "paths": '), /\.puprc is not valid JSON/) + }) + + it('refuses a file with no version files to bump', () => { + assert.throws(() => readPuprc('{}'), /no paths\.versions/) + assert.throws(() => readPuprc('{"paths":{"versions":[]}}'), /no paths\.versions/) + }) + }) + + describe('hasChangeloggerConfig', () => { + it('is true only for a package.json with a changelogger section', () => { + assert.equal(hasChangeloggerConfig(JSON.stringify(PACKAGE_JSON)), true) + assert.equal(hasChangeloggerConfig('{"name":"x"}'), false) + assert.equal(hasChangeloggerConfig(null), false) + assert.equal(hasChangeloggerConfig('not json'), false) + }) + }) + + describe('the pup commands', () => { + it('downloads the pinned release', () => { + assert.equal( + pupDownloadUrl('2.0.0'), + 'https://github.com/stellarwp/pup/releases/download/2.0.0/pup.phar' + ) + }) + + it('bumps the version files, then replaces the TBDs', () => { + assert.deepEqual(pupCommands('/tmp/pup.phar', '4.17.0'), [ + { command: 'php', args: ['/tmp/pup.phar', 'replace-version', '4.17.0'] }, + { command: 'php', args: ['/tmp/pup.phar', 'replace-tbd', '4.17.0'] }, + ]) + }) + }) + + describe('the push credential', () => { + it('is the basic credential git uses for a token', () => { + const credential = basicCredential('ghs_abc') + + assert.equal(Buffer.from(credential, 'base64').toString(), 'x-access-token:ghs_abc') + }) + + it('reaches git through its environment, scoped to the server, after clearing the key', () => { + // The empty first value resets the list, dropping a header the checkout persisted. + assert.deepEqual(pushEnv('ghs_abc', 'https://github.com'), { + GIT_CONFIG_COUNT: '2', + GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader', + GIT_CONFIG_VALUE_0: '', + GIT_CONFIG_KEY_1: 'http.https://github.com/.extraheader', + GIT_CONFIG_VALUE_1: `AUTHORIZATION: basic ${basicCredential('ghs_abc')}`, + }) + }) + + it('adds nothing without a token, leaving the checkout credentials to git', () => { + assert.deepEqual(pushEnv('', 'https://github.com'), {}) + }) + }) + + describe('compareVersions', () => { + it('orders versions by their numeric parts, a missing part counting as 0', () => { + assert.equal(compareVersions('4.17.0', '4.18.0'), -1) + assert.equal(compareVersions('4.18.0', '4.17.9'), 1) + assert.equal(compareVersions('4.18.0', '4.18.0'), 0) + assert.equal(compareVersions('4.18.0', '4.18.0.0'), 0) + assert.equal(compareVersions('4.18.0.1', '4.18.0'), 1) + assert.equal(compareVersions('4.10.0', '4.9.0'), 1) + }) + }) + + describe('readCurrentVersion', () => { + it('reads the version pup prints, ignoring anything before it', () => { + assert.equal(readCurrentVersion('4.18.0\n'), '4.18.0') + assert.equal(readCurrentVersion('PHP Deprecated: something\n4.18.0.1\n'), '4.18.0.1') + }) + + it('reads the numeric part of a version with a suffix', () => { + assert.equal(readCurrentVersion('4.19.0-beta.1\n'), '4.19.0') + }) + + it('returns null when pup could not read a version', () => { + assert.equal(readCurrentVersion('unknown\n'), null) + assert.equal(readCurrentVersion(''), null) + }) + }) + + describe('commitMessage', () => { + it('names the branch and the version', () => { + assert.equal(commitMessage('release/M26.jigglypuff', '4.17.0'), 'Prepare release/M26.jigglypuff (4.17.0)') + }) + }) + + describe('runPrepare', () => { + it('checks the ref, downloads pup, bumps the versions and then replaces the TBDs', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await runPrepare({ core, exec, env: prepareEnv(), cwd }) + + const phar = '/runner/temp/pup-2.0.0.phar' + assert.deepEqual(sequence(calls), [ + 'git check-ref-format refs/heads/release/M26.jigglypuff', + 'git symbolic-ref --quiet --short HEAD', + 'curl -fsSI https://github.com/stellarwp/pup/releases/download/2.0.0/pup.phar', + `curl -fsSL --retry 3 -o ${phar} https://github.com/stellarwp/pup/releases/download/2.0.0/pup.phar`, + `php -d display_errors=stderr ${phar} get-version`, + `php ${phar} replace-version 4.17.0`, + `php ${phar} replace-tbd 4.17.0`, + ]) + assert.ok( + calls.filter((call) => call.command === 'php').every((call) => call.options.cwd === cwd), + 'pup runs in the checkout' + ) + assert.deepEqual(outputs(), { + version: '4.17.0', + date: TODAY, + branch: 'release/M26.jigglypuff', + distribution: 'phar', + }) + }) + + it('hands the changelog step a date it can write, never `today`', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await runPrepare({ core, exec, env: prepareEnv({ INPUT_DATE: 'today' }), cwd }) + assert.equal(outputs().date, TODAY) + }) + + it('passes a given date through', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await runPrepare({ core, exec, env: prepareEnv({ INPUT_DATE: '2026-10-06' }), cwd }) + assert.equal(outputs().date, '2026-10-06') + }) + + it('stops before running anything when the version, the date or pup-version is wrong', async (t) => { + const cases = [ + [{ INPUT_VERSION: '' }, /No version was given/], + [{ INPUT_VERSION: '4.17.0-beta.1' }, /not a version this action can prepare/], + [{ INPUT_VERSION: '2.1..21' }, /not a version this action can prepare/], + [{ INPUT_DATE: 'ontem' }, /is not a date/], + [{ INPUT_BRANCH: '' }, /No branch was given/], + ] + + for (const [overrides, message] of cases) { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects(runPrepare({ core, exec, env: prepareEnv(overrides), cwd }), message) + assert.deepEqual(calls, [], `nothing ran for ${JSON.stringify(overrides)}`) + assert.deepEqual(outputs(), {}) + } + }) + + it('stops before downloading pup when the branch is not a valid name', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([{ command: 'git', args: /^check-ref-format /, exitCode: 1 }]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv({ INPUT_BRANCH: 'release/..x' }), cwd }), + /'release\/\.\.x' is not a valid branch name/ + ) + assert.deepEqual(sequence(calls), ['git check-ref-format refs/heads/release/..x']) + }) + + it('stops before downloading pup when the checkout is on another branch', async (t) => { + // The push writes HEAD to refs/heads/. From another branch that the release branch is an + // ancestor of, it would be accepted as a fast-forward and carry that branch's commits along. + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([{ command: 'git', args: /^symbolic-ref /, stdout: 'main\n' }]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv(), cwd }), + /The checkout is on 'main', not on 'release\/M26\.jigglypuff'.*Nothing was changed/s + ) + assert.ok(!calls.some((call) => call.command === 'curl' || call.command === 'php')) + }) + + it('stops before downloading pup when the checkout is on no branch at all', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([{ command: 'git', args: /^symbolic-ref /, exitCode: 1 }]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv(), cwd }), + /The checkout is on a detached commit, not on 'release\/M26\.jigglypuff'/ + ) + assert.ok(!calls.some((call) => call.command === 'curl' || call.command === 'php')) + }) + + it('refuses a version lower than the one the branch already has, before changing anything', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([{ command: 'php', args: /get-version$/, stdout: '4.18.0\n' }]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv(), cwd }), + /Refusing to prepare 4\.17\.0: release\/M26\.jigglypuff already has 4\.18\.0.*Nothing was changed/s + ) + assert.ok(!sequence(calls).some((call) => / replace-(version|tbd) /.test(call)), 'pup changed nothing') + assert.deepEqual(outputs(), { distribution: 'phar' }) + }) + + it('compares a four-part version by all four parts', async (t) => { + for (const [current, version, accepted] of [ + ['4.18.0.1', '4.18.0', false], + ['4.18.0.1', '4.18.0.2', true], + ['4.18.0.1', '4.18.1', true], + ]) { + const { core } = await coreWithOutputs(t) + const { exec } = recordingExec( + allSucceed([ + { command: 'git', args: /^symbolic-ref /, stdout: `release/${version}\n` }, + { command: 'php', args: /get-version$/, stdout: `${current}\n` }, + ]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + const env = prepareEnv({ INPUT_VERSION: version, INPUT_BRANCH: `release/${version}` }) + const prepare = runPrepare({ core, exec, env, cwd }) + + if (accepted) { + await prepare + } else { + await assert.rejects(prepare, /already has 4\.18\.0\.1/) + } + } + }) + + it('prepares the version the branch already has, as a re-run of the same release does', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([{ command: 'php', args: /get-version$/, stdout: '4.17.0\n' }]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await runPrepare({ core, exec, env: prepareEnv(), cwd }) + + assert.ok(sequence(calls).some((call) => call.endsWith('replace-version 4.17.0'))) + assert.equal(outputs().version, '4.17.0') + }) + + it('warns and goes on when the branch holds a version that is not numeric', async (t) => { + const { core, logged } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([{ command: 'php', args: /get-version$/, stdout: 'dev\n' }]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await runPrepare({ core, exec, env: prepareEnv(), cwd }) + + assert.match(logged(), /::warning::The version files hold `dev`, which is not a numeric version/) + assert.ok(sequence(calls).some((call) => call.endsWith('replace-version 4.17.0'))) + }) + + it('fails with pup output when pup cannot find the current version, and changes nothing', async (t) => { + // pup refuses to load a .puprc whose version regex finds nothing, so get-version exits 1. + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([ + { + command: 'php', + args: /get-version$/, + exitCode: 1, + stdout: 'ERROR: Could not find version in file plugin.php using regex "/(Version: )(.+)/"', + }, + ]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv(), cwd }), + /pup get-version failed.*Could not find version in file plugin\.php/s + ) + assert.ok(!sequence(calls).some((call) => / replace-(version|tbd) /.test(call)), 'pup changed nothing') + }) + + it('stops before running pup when the repository has no usable .puprc', async (t) => { + const cases = [ + [{}, /No \.puprc/], + [{ '.puprc': '{ "paths": ' }, /\.puprc is not valid JSON/], + [{ '.puprc': {} }, /no paths\.versions/], + ] + + for (const [files, message] of cases) { + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ 'package.json': PACKAGE_JSON, ...files }) + + await assert.rejects(runPrepare({ core, exec, env: prepareEnv(), cwd }), message) + assert.ok( + !calls.some((call) => call.command === 'curl' || call.command === 'php'), + `pup never ran, got ${JSON.stringify(sequence(calls))}` + ) + } + }) + + it('warns when the .puprc leaves the TBD directories to pup', async (t) => { + const { core, logged } = await coreWithOutputs(t) + const { exec } = recordingExec(allSucceed()) + const { checks, ...withoutChecks } = PUPRC + const cwd = checkoutWith({ '.puprc': withoutChecks, 'package.json': PACKAGE_JSON }) + + await runPrepare({ core, exec, env: prepareEnv(), cwd }) + + assert.match(logged(), /::warning::.*checks\.tbd\.dirs.*src\//) + }) + + it('warns when package.json has no changelogger section', async (t) => { + // The changelogger falls back to its defaults without one, and writes a changelog.md. + const { core, logged } = await coreWithOutputs(t) + const { exec } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': { name: 'x' } }) + + await runPrepare({ core, exec, env: prepareEnv(), cwd }) + + assert.match(logged(), /::warning::.*changelogger/) + }) + + it('fails with pup output when pup fails, and stops there', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([ + { + command: 'php', + args: /replace-version/, + exitCode: 1, + stdout: 'No version files found in .puprc paths.versions.', + }, + ]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv(), cwd }), + /pup replace-version failed.*No version files found/s + ) + assert.ok(!sequence(calls).some((call) => call.includes('replace-tbd')), 'replace-tbd never ran') + assert.deepEqual(outputs(), { distribution: 'phar' }) + }) + + it('fails when the pup download fails', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec } = recordingExec( + allSucceed([ + { command: 'curl', args: /fsSL/, exitCode: 22, stderr: 'The requested URL returned error: 404' }, + ]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv(), cwd }), + /Downloading pup 2\.0\.0 failed.*404/s + ) + }) + + it('resolves latest to the newest pup release and downloads that phar', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + const fetched = [] + const fetchImpl = async (url, options) => { + fetched.push([url, options]) + return { ok: true, json: async () => ({ tag_name: '2.0.0' }) } + } + + await runPrepare({ + core, + exec, + env: prepareEnv({ INPUT_PUP_VERSION: 'latest', INPUT_TOKEN: 'ghp_example' }), + cwd, + fetchImpl, + }) + + assert.deepEqual(fetched, [ + ['https://api.github.com/repos/stellarwp/pup/releases/latest', { headers: { authorization: 'Bearer ghp_example' } }], + ]) + assert.ok( + sequence(calls).some((call) => call.includes('pup/releases/download/2.0.0/pup.phar')), + 'downloads the phar the resolved release points at' + ) + }) + + it('fails loudly when the latest release reports no usable tag', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + const fetchImpl = async () => ({ + ok: true, + json: async () => ({ tag_name: undefined }), + }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv({ INPUT_PUP_VERSION: 'latest' }), cwd, fetchImpl }), + /The latest pup release has no usable tag/ + ) + assert.deepEqual(outputs(), {}, 'no distribution is decided from a broken response') + }) + + it('does not call the GitHub API for a pinned pup version', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + let called = 0 + const fetchImpl = async () => { + called += 1 + return { ok: true, json: async () => ({}) } + } + + await runPrepare({ core, exec, env: prepareEnv(), cwd, fetchImpl }) + + assert.equal(called, 0, 'a pinned version downloads straight from the release URL') + }) + + it('fails loudly when the release has no phar, pointing at the pin', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([ + { command: 'curl', args: /fsSI/, exitCode: 22, stderr: 'The requested URL returned error: 404' }, + ]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv(), cwd }), + /pup 2\.0\.0 publishes no \.phar asset.*Pin pup-version to a release that ships a \.phar/s + ) + assert.deepEqual(outputs(), { distribution: 'ts' }, 'the resolved distribution is reported on the failed run') + assert.ok( + !sequence(calls).some((call) => /fsSL|php /.test(call)), + 'no phar is downloaded and no PHP runs without a phar asset' + ) + }) + + it('fails loudly when the phar check itself errors', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec } = recordingExec( + allSucceed([{ command: 'curl', args: /fsSI/, exitCode: 28, stderr: 'Connection timed out' }]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv(), cwd }), + /Checking the pup 2\.0\.0 release failed.*Connection timed out/s + ) + }) + }) + + describe('runCommit', () => { + it('commits every change and pushes it to the release branch', async (t) => { + const { core, outputs, summary } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + const { work, remote } = repositoryOn('release/M26.jigglypuff') + fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.17.0\n') + fs.writeFileSync(path.join(work, 'changelog.txt'), '= 4.17.0 =\n') + + await runCommit({ core, exec, env: commitEnv(), cwd: work }) + + const pushed = git(remote, 'rev-parse', 'refs/heads/release/M26.jigglypuff') + assert.equal(pushed, git(work, 'rev-parse', 'HEAD')) + assert.equal(git(remote, 'log', '-1', '--format=%s', pushed), 'Prepare release/M26.jigglypuff (4.17.0)') + assert.equal( + git(remote, 'log', '-1', '--format=%an <%ae>', pushed), + 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>' + ) + assert.deepEqual(outputs(), { commit: pushed, files: 'changelog.txt\nplugin.php' }) + assert.match(summary(), /Prepare release\/M26\.jigglypuff \(4\.17\.0\)/) + assert.match(summary(), /^- `changelog\.txt`\n- `plugin\.php`$/m) + }) + + it('never puts the token on a command line, in the log or in the git config', async (t) => { + const { core, logged } = await coreWithOutputs(t) + const { exec, calls, printed } = await recordedRealExec() + const { work } = repositoryOn('release/M26.jigglypuff') + fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.17.0\n') + const token = commitEnv().INPUT_TOKEN + const credential = basicCredential(token) + + await runCommit({ core, exec, env: commitEnv(), cwd: work }) + + assert.match(printed(), /\[command\].*git push origin HEAD:refs\/heads\/release\/M26\.jigglypuff/) + const log = `${logged()}\n${printed()}` + assert.match(log, new RegExp(`::add-mask::${token}`)) + assert.match(log, new RegExp(`::add-mask::${credential}`)) + + const unmasked = log.split('\n').filter((line) => !line.startsWith('::add-mask::')) + assert.ok(!unmasked.some((line) => line.includes(token) || line.includes(credential))) + assert.ok(!calls.some((call) => call.args.join(' ').includes(credential))) + assert.ok(!fs.readFileSync(path.join(work, '.git', 'config'), 'utf8').includes(credential)) + + const push = calls.find((call) => call.args[0] === 'push') + assert.deepEqual(push.args, ['push', 'origin', 'HEAD:refs/heads/release/M26.jigglypuff']) + assert.equal(push.options.env.GIT_CONFIG_VALUE_1, `AUTHORIZATION: basic ${credential}`) + }) + + it('sends only its own credential when the checkout persisted another', async (t) => { + // actions/checkout persists its token under the same http./.extraheader key, and git + // sends every value of that key. Two Authorization headers make GitHub answer 400. + const http = require('node:http') + const seen = [] + const server = http.createServer((request, response) => { + const { rawHeaders } = request + seen.push( + rawHeaders.filter((value, index) => index % 2 === 1 && /^authorization$/i.test(rawHeaders[index - 1])) + ) + response.writeHead(404).end() + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + t.after(() => server.close()) + + const serverUrl = `http://127.0.0.1:${server.address().port}` + const { core } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + const { work } = repositoryOn('release/M26.jigglypuff') + git(work, 'remote', 'set-url', 'origin', `${serverUrl}/stellarwp/plugin.git`) + git(work, 'config', `http.${serverUrl}/.extraheader`, 'AUTHORIZATION: basic cGVyc2lzdGVk') + fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.17.0\n') + const env = commitEnv({ GITHUB_SERVER_URL: serverUrl, GIT_TERMINAL_PROMPT: '0' }) + + // The server has no repository, so the push fails once git has sent its first request. + await assert.rejects(runCommit({ core, exec, env, cwd: work }), /Pushing to release\/M26\.jigglypuff failed/) + + assert.ok(seen.length > 0, 'git reached the server') + for (const headers of seen) { + assert.deepEqual(headers, [`basic ${basicCredential(env.INPUT_TOKEN)}`]) + } + }) + + it('neither commits nor pushes when nothing changed, and still succeeds', async (t) => { + // A retry of the same version finds the branch already prepared. + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = await recordedRealExec() + const { work, remote } = repositoryOn('release/M26.jigglypuff') + const before = git(remote, 'rev-parse', 'refs/heads/release/M26.jigglypuff') + + await runCommit({ core, exec, env: commitEnv(), cwd: work }) + + assert.equal(git(remote, 'rev-parse', 'refs/heads/release/M26.jigglypuff'), before) + assert.ok(!calls.some((call) => call.args.includes('commit') || call.args.includes('push'))) + assert.deepEqual(outputs(), { commit: '', files: '' }) + }) + + it('fails with git output when the push is refused', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + const { work, remote } = repositoryOn('release/M26.jigglypuff') + + // Someone else pushed to the branch after the checkout, so the push is not a fast-forward. + const other = path.join(path.dirname(remote), 'other') + git(path.dirname(remote), 'clone', '--quiet', '-b', 'release/M26.jigglypuff', remote, other) + git(other, 'config', 'user.name', 'Other') + git(other, 'config', 'user.email', 'other@example.com') + git(other, 'commit', '--quiet', '--allow-empty', '-m', 'Elsewhere') + git(other, 'push', '--quiet', 'origin', 'HEAD:refs/heads/release/M26.jigglypuff') + + fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.17.0\n') + + await assert.rejects( + runCommit({ core, exec, env: commitEnv(), cwd: work }), + /Pushing to release\/M26\.jigglypuff failed.*rejected/s + ) + }) + }) +}) diff --git a/tests/support/actions.js b/tests/support/actions.js index 9b4765d..35a02a4 100644 --- a/tests/support/actions.js +++ b/tests/support/actions.js @@ -12,6 +12,10 @@ * outputs are read back the way a later workflow step would see them. * * Both packages are ESM only, so they are loaded with dynamic import and these factories are async. + * + * `exec` is the one stand-in. An action that shells out (git, php) would otherwise need those tools + * and a network at test time, so recordingExec answers from rules the way recordingFetch does, and + * records the calls for the test to assert. */ const fs = require('node:fs') @@ -135,6 +139,22 @@ function readOutputs(file) { let realStdoutWrite = null let collecting = null +/** + * Whether a stdout write is the action's log, which a test collects, or the test runner's own. + * + * node --test runs each file in a child process that reports its results to the parent over stdout, + * as serialized Buffers written whenever the runner flushes, which can be in the middle of a later + * test that awaits. Collecting those swallowed the reports of every test before it, and the file + * counted as a single test. @actions/core writes its log as strings, so only strings are collected. + * + * @param {string|Uint8Array} chunk What was written. + * + * @returns {boolean} True for a write to collect. + */ +function isLogWrite(chunk) { + return typeof chunk === 'string' +} + /** * Collects what is written to stdout for the length of one test. * @@ -157,7 +177,7 @@ function captureStdout(t) { realStdoutWrite = process.stdout.write.bind(process.stdout) process.stdout.write = (chunk, encoding, callback) => { - if (!collecting) { + if (!collecting || !isLogWrite(chunk)) { return realStdoutWrite(chunk, encoding, callback) } @@ -182,6 +202,30 @@ function captureStdout(t) { return () => written.join('') } +/** The job summary file for this process, created on first use. */ +let summaryFile = null + +/** + * Collects what is written to the job summary for the length of one test. + * + * core.summary resolves GITHUB_STEP_SUMMARY on its first write and keeps the path for the life of + * the process, so a fresh file per test would be ignored after the first one. There is one file + * instead, and each test reads only what was appended after it began. + * + * @returns {Function} Reads back everything written to the summary since this call. + */ +function captureSummary() { + if (!summaryFile) { + summaryFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'toolbox-summary-')), 'summary') + fs.writeFileSync(summaryFile, '') + process.env.GITHUB_STEP_SUMMARY = summaryFile + } + + const from = fs.statSync(summaryFile).size + + return () => fs.readFileSync(summaryFile, 'utf8').slice(from) +} + /** * The real @actions/core, pointed at a fresh GITHUB_OUTPUT file, with its log captured. * @@ -194,10 +238,13 @@ function captureStdout(t) { * keeps the run clean and lets a test assert what was logged. stdout is restored when the test ends, * before the runner reports the result. * + * The job summary is collected too, so an action that writes one can be asserted on. + * * @param {object} t The node:test context, used to restore stdout afterwards. * - * @returns {Promise<{core: object, outputs: Function, logged: Function}>} The toolkit, a reader for - * its outputs, and a reader for everything it wrote to the log. + * @returns {Promise<{core: object, outputs: Function, logged: Function, summary: Function}>} The + * toolkit, and readers for its outputs, for everything it wrote to the log and for what it + * wrote to the job summary. */ async function coreWithOutputs(t) { const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'toolbox-output-')), 'output') @@ -206,7 +253,59 @@ async function coreWithOutputs(t) { const core = await import('@actions/core') - return { core, outputs: () => readOutputs(file), logged: captureStdout(t) } + return { core, outputs: () => readOutputs(file), logged: captureStdout(t), summary: captureSummary() } +} + +/** + * Stands in for @actions/exec: answers each call from a list of rules and records it. + * + * A rule matches on `command` and, when it has one, on `args`, a RegExp tested against the + * arguments joined by spaces. The first match wins. It answers with `exitCode` (default 0), + * `stdout` and `stderr`, and runs `effect(args, options)` first when it has one, which is how a test + * stands in for a file the command would have written. + * + * A call no rule matches answers exit code 127, the shell's "command not found", so a call the test + * did not plan for fails rather than passing silently. + * + * Like the real package, a non-zero exit rejects unless the caller passed `ignoreReturnCode`. + * + * @param {object[]} rules `{command, args, exitCode, stdout, stderr, effect}`. + * + * @returns {{exec: object, calls: object[]}} The stand-in, with `exec` and `getExecOutput`, and the + * calls it recorded as `{command, args, options}`. + */ +function recordingExec(rules = []) { + const calls = [] + + const getExecOutput = async (command, args = [], options = {}) => { + calls.push({ command, args, options }) + + const rule = rules.find( + (candidate) => + candidate.command === command && (!candidate.args || candidate.args.test(args.join(' '))) + ) + + if (rule?.effect) { + await rule.effect(args, options) + } + + const result = rule + ? { exitCode: rule.exitCode ?? 0, stdout: rule.stdout ?? '', stderr: rule.stderr ?? '' } + : { exitCode: 127, stdout: '', stderr: `${command}: no test rule answers this call` } + + if (result.exitCode !== 0 && !options.ignoreReturnCode) { + throw new Error(`The process '${command}' failed with exit code ${result.exitCode}`) + } + + return result + } + + const exec = { + getExecOutput, + exec: async (command, args, options) => (await getExecOutput(command, args, options)).exitCode, + } + + return { exec, calls } } /** @@ -216,14 +315,22 @@ async function coreWithOutputs(t) { * @param {object[]} rules Passed to recordingFetch. * * @returns {Promise<{github: object, requests: object[], core: object, outputs: Function, - * logged: Function}>} Everything a test needs: the client, the requests it records, the - * toolkit, and readers for its outputs and its log. + * logged: Function, summary: Function}>} Everything a test needs: the client, the + * requests it records, the toolkit, and readers for its outputs, its log and its summary. */ async function actionsFor(t, rules = []) { const { github, requests } = await octokitFor(rules) - const { core, outputs, logged } = await coreWithOutputs(t) + const { core, outputs, logged, summary } = await coreWithOutputs(t) - return { github, requests, core, outputs, logged } + return { github, requests, core, outputs, logged, summary } } -module.exports = { actionsFor, octokitFor, coreWithOutputs, recordingFetch, readOutputs } +module.exports = { + actionsFor, + octokitFor, + coreWithOutputs, + recordingFetch, + recordingExec, + readOutputs, + isLogWrite, +} diff --git a/tests/support/actions.test.js b/tests/support/actions.test.js index 40c021f..4788a1c 100644 --- a/tests/support/actions.test.js +++ b/tests/support/actions.test.js @@ -3,7 +3,7 @@ const { describe, it } = require('node:test') const assert = require('node:assert') -const { actionsFor } = require('./actions.js') +const { actionsFor, coreWithOutputs, recordingExec, isLogWrite } = require('./actions.js') describe('tests/support/actions', () => { describe('the captured log', () => { @@ -32,4 +32,73 @@ describe('tests/support/actions', () => { assert.equal(first.logged(), '', 'the earlier one stopped when the second began') }) }) + + describe('isLogWrite', () => { + it("collects the toolkit's string writes and leaves the runner's Buffers alone", () => { + // The runner reports results as Buffers over the same stdout. Collecting them swallowed the + // report of every test that finished while a later one was awaiting. + assert.equal(isLogWrite('::warning::a warning\n'), true) + assert.equal(isLogWrite(Buffer.from('report')), false) + assert.equal(isLogWrite(new Uint8Array([1, 2])), false) + }) + }) + + describe('the job summary', () => { + it('holds what the toolkit wrote to it during the test', async (t) => { + const { core, summary } = await coreWithOutputs(t) + + await core.summary.addRaw('a summary line').write() + + assert.match(summary(), /a summary line/) + }) + }) + + describe('recordingExec', () => { + it('answers from the first matching rule and records every call', async () => { + const { exec, calls } = recordingExec([ + { command: 'git', args: /^status/, stdout: ' M a.php\n' }, + { command: 'git', stdout: 'any other git call' }, + ]) + + const status = await exec.getExecOutput('git', ['status', '--porcelain']) + const other = await exec.getExecOutput('git', ['rev-parse', 'HEAD'], { cwd: '/repo' }) + + assert.deepEqual(status, { exitCode: 0, stdout: ' M a.php\n', stderr: '' }) + assert.equal(other.stdout, 'any other git call') + assert.deepEqual(calls, [ + { command: 'git', args: ['status', '--porcelain'], options: {} }, + { command: 'git', args: ['rev-parse', 'HEAD'], options: { cwd: '/repo' } }, + ]) + }) + + it('fails a non-zero exit the way @actions/exec does, unless the caller ignores it', async () => { + const { exec } = recordingExec([{ command: 'php', exitCode: 1, stderr: 'boom' }]) + + await assert.rejects(exec.getExecOutput('php', ['x']), /The process 'php' failed with exit code 1/) + await assert.rejects(exec.exec('php', ['x']), /The process 'php' failed with exit code 1/) + + const ignored = await exec.getExecOutput('php', ['x'], { ignoreReturnCode: true }) + + assert.deepEqual(ignored, { exitCode: 1, stdout: '', stderr: 'boom' }) + }) + + it('answers a call no rule planned for as a missing command, never as a success', async () => { + const { exec } = recordingExec([]) + + const result = await exec.getExecOutput('curl', ['-fsSL'], { ignoreReturnCode: true }) + + assert.equal(result.exitCode, 127) + }) + + it('runs a rule effect, so a test can stand in for what the command writes', async () => { + const written = [] + const { exec } = recordingExec([ + { command: 'php', effect: (args, options) => written.push([args[0], options.cwd]) }, + ]) + + await exec.exec('php', ['pup.phar'], { cwd: '/repo' }) + + assert.deepEqual(written, [['pup.phar', '/repo']]) + }) + }) })