diff --git a/.github/actions/release-prep/README.md b/.github/actions/release-prep/README.md new file mode 100644 index 0000000..6aa2b04 --- /dev/null +++ b/.github/actions/release-prep/README.md @@ -0,0 +1,239 @@ +# release-prep + +Prepares a release branch: bumps the version, replaces the `@since TBD` placeholders, writes the +changelog, then commits the result to the branch through the GitHub API: + +```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 to `branch` as `Prepare ()`, +through the GitHub API, by the identity of `token`: `github-actions[bot]` for the default token. Run +the action on a clean checkout, and put any step that writes tracked files after it: the commit +includes every change in the working tree. See [Committing](#committing). + +pup is downloaded from its GitHub release into the runner's temp directory, so it never ends up in +the commit. See [Which pup runs](#which-pup-runs). + +## 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. + +Three cases only produce a warning; the run continues: + +- No `checks.tbd.dirs`: pup falls back to its default and replaces TBDs under `src/` only. +- No `changelogger` section: the changelogger uses its defaults — a keepachangelog `changelog.md` + with semver versioning — and the action validates the version as semver. +- A custom versioning strategy, `versioning` set to a `.js`: the action does not + check the version's shape. See [Versions it refuses](#versions-it-refuses). + +A writing strategy given as a `.js` file is loaded from the checkout; it has to be there before the +action runs. One written in TypeScript has to be built in an earlier step. + +## Versions it refuses + +The version is checked against the versioning strategy the repository's changelogger configures, +`changelogger.versioning` in `package.json`, so the action refuses what the changelog step would +refuse, before pup has touched anything: + +- `stellarwp`: three or four numeric parts, such as `4.17.0` or `4.17.0.1`. A pre-release such as + `4.17.0-beta.1` is refused, even though it is a valid tag name. +- `semver`, or no `changelogger` section: `MAJOR.MINOR.PATCH` with an optional pre-release, such as + `4.17.0` or `4.17.0-beta.1`. A fourth part, `4.17.0.1`, is refused. So is a leading `v`: the + changelogger's semver check would take `v4.17.0`, but the version goes into the version files + and the tag as it is given. +- A custom strategy, `versioning` set to a `.js` or `.ts` file: no shape check. The changelog step + validates the version with that strategy, and the action warns that it did not. +- Any other `versioning` value fails the action, because the changelog step would fail on it. + +The changelog step validates the version only when `changelog/` holds entries to write; without +any, it writes nothing and accepts any version. The action's check does not depend on that. + +- **An empty version is refused.** A tool that dispatches the workflow for a release with no version + set sends an empty one. When the workflow declares `version` as required, as the template does, + GitHub refuses the dispatch itself with HTTP 422 and no run starts. When the workflow declares it + optional, the run starts and the action fails it with "No version was given". Either way the + release needs a version before the step runs again. +- **A 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. Only the numeric start of the + version is compared: `4.17.0-beta.1` counts as `4.17.0`, so it is accepted on a branch at `4.17.0` + and refused on one at `4.18.0`. When the version files hold something that is not a numeric + version, such as `dev`, or the version itself does not start with a number, the job only warns. + When pup cannot find a version at all, it fails, as `replace-version` would. + +Each refusal happens before anything is changed. So do a `date` that is not `YYYY-MM-DD`, a `branch` +that is not a valid branch name, and a checkout that is not on `branch`. + +## Inputs + +| Input | Required | Default | What it does | +|---|---|---|---| +| `branch` | yes | | The release branch to commit to, e.g. `release/M26.jigglypuff`. A branch name, not `refs/heads/…`. The checkout has to be on it; see [Committing](#committing) | +| `version` | yes | | The version to prepare, e.g. `4.17.0`. See [Versions it refuses](#versions-it-refuses) | +| `date` | no | today, in UTC | The changelog date as `YYYY-MM-DD`. Empty or `today` writes today | +| `token` | no | `github.token` | The token the commit is created with. See [Committing](#committing) | +| `pup-version` | no | `latest` | The pup release to run: a number such as `2.0.0`, or `latest` for the newest. See [Which pup runs](#which-pup-runs) | + +## Outputs + +| 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 created on the branch. Empty when nothing changed | +| `files` | `my-plugin.php` | The files the commit changed, one per line. Empty when nothing changed | +| `distribution` | `phar` | Which pup distribution the release carries. `phar` on every run that succeeds, the only distribution the action runs today | + +## Which pup runs + +`pup-version` pins a pup release (`2.0.0`) or tracks the newest one (`latest`, the default). A +pinned release downloads straight from its release page. `latest` reads the newest release through +the GitHub API, authenticated with `token` so the runner's shared unauthenticated quota is not +spent. + +The release itself decides whether the action can run it, not a version comparison: the action +sends a HEAD request to the release's `pup.phar` asset. Every release from 1.2.4 to 2.0.0 +publishes one; the phar downloads into the runner's temp directory and runs on the runner's PHP. +A release without one is not supported yet: the prepare step fails, saying so and naming a pin +to use instead. A pinned release that does not exist, such as a mistyped number, fails the step +with "is not a release"; the action tells the two apart with a second HEAD request, to the +release's page. + +The TypeScript line of pup publishes no phar. How the action will call it is open until that +line ships a distribution: through pup's own GitHub Action, or straight from the script with the +arguments the phar takes. The manifest cannot carry the first behind a condition today, because a +composite action's `uses:` references resolve when the job is set up, not when the step runs, so +a reference to an action that does not exist yet breaks every run; the second needs something +published to call. Either way the change is local to the script, and the action's inputs and +outputs stay as they are. The branch-version check — the refusal of a version lower than the +branch already has — runs on the phar path only; the TS contract does not define it yet. + +## Running it again + +Preparing a version the branch already holds changes nothing: the versions match, no TBDs are left +and `changelog/` is empty. The action commits nothing, calls no API, 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. + +## Committing + +The job needs `contents: write`: + +```yaml +jobs: + prepare: + permissions: + contents: write +``` + +The commit is created through the GitHub API with `token`, not pushed with git. The action lists +the changes with git (`git add --all`, then `git diff --cached --raw`), uploads each changed file +as a blob, builds a tree on the checkout's own tree, creates a commit whose parent is the +checkout's own head, and points `branch` at it. Nothing is pushed, so the checkout can use +`persist-credentials: false`, and no token reaches git or the log. GitHub signs commits created +this way, so they show as verified. + +The ref update is not forced. If the branch moved after the checkout, GitHub refuses the update +as it would refuse a push, the job fails saying so, and the files of the checkout are never +written over newer ones. + +The checkout itself is left as it was: its HEAD stays on the commit it had, with the prepared +files in the working tree. A later step that reads the branch with git sees the commit before the +preparation; the `commit` output holds the new one. + +The commit goes to the repository the workflow runs in (`GITHUB_REPOSITORY`). Only regular files +are uploaded; a changed symlink or submodule fails the step, naming the path. + +The checkout has to be on `branch` itself, and the action checks that before changing anything. +Committing from another branch would carry that branch's commits into the release whenever the +release branch is an ancestor of it — for instance a run started from `main` whose checkout was +given no `ref`. A detached checkout is refused for the same reason. + +Pass a PAT or app token as `token` when a ruleset on the release branches blocks `github.token`, +or when a workflow in the repository has to run on the commit. GitHub creates no workflow run for +a commit made with `GITHUB_TOKEN`. + +## How it runs + +Two [actions/github-script](https://github.com/actions/github-script) steps run `release-prep.js`, +with the changelogger action between them. The first validates the inputs, the `.puprc` and the +changelogger strategy, then runs pup. The second commits through the API. github-script supplies +the Node runtime, `exec` and the `github` client, built from `token`; there is no dependency +to install and no bundle to build. + +The script is loaded by path rather than inlined in the manifest because github-script's `require` +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 step runs real git against a local checkout to list the +changes, and sends the commit to an Octokit whose network is a stand-in that records every +request. Run `npm install` once, then `node --test`. diff --git a/.github/actions/release-prep/action.yml b/.github/actions/release-prep/action.yml new file mode 100644 index 0000000..a1cf91d --- /dev/null +++ b/.github/actions/release-prep/action.yml @@ -0,0 +1,114 @@ +# Prepares a release branch: bumps the version, replaces the TBD placeholders, writes the changelog, +# then commits that to the branch through the GitHub API. It opens no pull request, so the change +# is reviewed wherever the release itself is reviewed. +# +# The checks and the commit live in release-prep.js, run by actions/github-script, so there is no +# dependency to install and nothing to build. The changelog is written by the changelogger's own +# action between the two script steps, rather than reimplemented here. +name: Release prep +description: Bumps the version, replaces TBDs and writes the changelog, then commits it to the release branch + +inputs: + branch: + description: >- + The release branch to commit the preparation to, e.g. `release/M26.jigglypuff`. A branch name, not + any git ref. The action does not check it out: the caller's checkout has to be on it, and the + action refuses to run when it is not. + required: true + version: + description: >- + The version to prepare. It is validated against the versioning strategy the repository's + changelogger configures: three or four numeric parts for `stellarwp`, a semver version for + `semver` or no configuration, no shape check for a custom strategy. An empty value and a + version lower than the one the branch already has are refused before anything is changed. + required: true + date: + description: >- + 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 created with, through the GitHub API. It needs `contents: write` on the + repository, so the job needs that permission too. Nothing is pushed with git, so the checkout + can use `persist-credentials: false`. + required: false + default: ${{ github.token }} + pup-version: + 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. It has to ship a pup.phar asset, which every + release from 1.2.4 to 2.0.0 does. A release without one is not supported yet: the action + fails and names a pin to use instead. + required: false + default: 'latest' + +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 created on the branch. Empty when nothing changed and nothing was committed.' + value: ${{ steps.commit.outputs.commit }} + files: + description: 'The files the commit changed, one per line. Empty when nothing changed.' + value: ${{ steps.commit.outputs.files }} + distribution: + description: >- + Which pup distribution the release carries. On a run that succeeds it is `phar`, the only + distribution the action runs today. + value: ${{ steps.prepare.outputs.distribution }} + +runs: + using: 'composite' + 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 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 }} + with: + # github-script builds its `github` client from its own token input, which defaults to + # github.token: the action's token has to be handed over here or it is never used. + github-token: ${{ inputs.token }} + script: | + const { runCommit } = require(`${process.env.ACTION_PATH}/release-prep.js`) + await runCommit({ github, 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..bbf991d --- /dev/null +++ b/.github/actions/release-prep/release-prep.js @@ -0,0 +1,910 @@ +'use strict' + +/** + * Prepares a release branch: bumps the version, replaces the TBD placeholders, and commits what the + * changelog step wrote alongside them to the branch, through the GitHub API. + * + * The action runs in two github-script steps with the changelogger action between them, so there + * are two entry points. runPrepare validates everything and runs pup; runCommit lists the changes + * with git and creates the commit with the Octokit github-script supplies. The functions they are + * built from are pure, apart from the reads of the checkout, the commands run through `exec` and + * the API calls, so a test can pass stand-ins and assert the calls and the requests. + */ + +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { execFileSync } = require('node:child_process') + +const { compareVersions } = require('../../lib/versions.js') + +/** + * Three or four numeric parts, the shape the stellarwp changelog versioning accepts. That strategy + * also accepts an empty part (it reads it as 0, so it would take 2.1..21); this pattern rejects + * that. A version of this shape is also always a valid tag name. + */ +const STELLARWP_VERSION_PATTERN = /^\d+\.\d+\.\d+(\.\d+)?$/ + +/** + * A semver version as semver.org defines it: MAJOR.MINOR.PATCH, an optional pre-release and + * optional build metadata. The changelogger's semver strategy validates with the semver package, + * which also takes a leading `v`; this pattern does not, because the version goes into the version + * files and the tag as it is given. + */ +const SEMVER_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?:[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ + +/** The numeric start of a version, which is what the branch-version comparison reads. */ +const NUMERIC_PREFIX = /^\d+(\.\d+)*/ + +/** A pup release number, which goes into the download URL. */ +const PUP_VERSION_PATTERN = /^\d+\.\d+\.\d+$/ + +/** A calendar date as the changelog writing strategies expect it. */ +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ + +/** The git modes of a regular file, the only kind of change the action uploads. */ +const BLOB_MODES = new Set(['100644', '100755']) + +/** + * Reads the version input, refusing an empty one. + * + * @param {string} version The `version` input. + * + * @throws {Error} When it is empty. + * + * @returns {string} The version, without surrounding whitespace. + */ +function readVersion(version) { + const trimmed = String(version ?? '').trim() + + if (trimmed === '') { + throw new Error( + 'No version was given. 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." + ) + } + + return trimmed +} + +/** + * Checks the version to prepare against the versioning strategy the repository's changelogger + * configures, so the action refuses what the changelog step would refuse, before pup has run. + * + * @param {string} version The `version` input. + * @param {'stellarwp'|'semver'|'custom'} strategy The strategy readChangeloggerStrategy read. + * + * @throws {Error} When it is empty, or not a version the strategy accepts. A custom strategy + * validates on its own, in the changelog step. + * + * @returns {string} The version, without surrounding whitespace. + */ +function validateVersion(version, strategy) { + const trimmed = readVersion(version) + + if (strategy === 'stellarwp' && !STELLARWP_VERSION_PATTERN.test(trimmed)) { + throw new Error( + `'${trimmed}' is not a version this action can prepare. It takes three or four numeric ` + + 'parts, such as 4.17.0 or 4.17.0.1. A pre-release such as 4.17.0-beta.1 is not supported, ' + + 'because the stellarwp changelog versioning rejects it. Nothing was changed.' + ) + } + + if (strategy === 'semver' && !SEMVER_PATTERN.test(trimmed)) { + throw new Error( + `'${trimmed}' is not a version this action can prepare. The changelogger validates it as ` + + 'semver: MAJOR.MINOR.PATCH with an optional pre-release, such as 4.17.0 or 4.17.0-beta.1, ' + + 'and no leading v. Nothing was changed.' + ) + } + + return trimmed +} + +/** + * 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 commit 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 commit updates refs/heads/, so a full ref would name a branch refs/heads/…. + if (trimmed.startsWith('refs/')) { + throw new Error( + `Refusing to prepare '${trimmed}': pass the branch name, e.g. ` + + `'${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: resolvePupDistribution checks it against the + * release page, so there is no API call for the common case. `latest` asks the GitHub API for + * the newest release, authenticated with the action token when the caller passes one, so the + * runner's shared unauthenticated quota is not spent. + * + * @param {object} options + * @param {string} options.version The validated `pup-version` input. + * @param {string} options.token The action token, possibly empty. + * @param {Function} options.fetchImpl What `fetch` to use; a test passes a stand-in. + * + * @throws {Error} When the latest release cannot be read. + * + * @returns {Promise} The release tag to download from. + */ +async function resolvePupRelease({ version, token, fetchImpl }) { + if (version.toLowerCase() !== 'latest') { + return version + } + + const headers = {} + if (token) { + headers.authorization = `Bearer ${token}` + } + + const response = await fetchImpl('https://api.github.com/repos/stellarwp/pup/releases/latest', { + headers, + }) + + if (!response.ok) { + throw new Error( + `Reading the latest pup release failed (HTTP ${response.status}). Pass a pinned pup-version and run this step again. Nothing was changed.` + ) + } + + // The tag goes into a download URL and a temp file path, and a missing one would surface + // as a 404 on …/download/undefined/pup.phar, which the distribution check would report as + // a release that does not exist. A broken API response must fail here, naming the API, so + // the tag is validated before it is used. + const tag = (await response.json()).tag_name + if (typeof tag !== 'string' || !PUP_VERSION_PATTERN.test(tag)) { + throw new Error( + 'The latest pup release has no usable tag. Pass a pinned pup-version and run this step ' + + 'again. Nothing was changed.' + ) + } + + return tag +} + +/** + * Reads what the action needs from the repository's .puprc. + * + * 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 never gets a commit + * with the version left as it was. + * + * @param {string|null} text The contents of .puprc, or null when there is none. + * + * @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 } +} + +/** + * Reads which versioning strategy the changelogger will validate the version with. + * + * The changelogger reads `changelogger.versioning` from package.json and falls back to `semver` + * without the section, without the file, or when the file is not JSON; it loads a `.js` or `.ts` + * value as a custom strategy with its own validation, and fails on any other value before it looks + * at the changes, so that case fails here, before pup has run. + * + * @param {string|null} text The contents of package.json, or null when there is none. + * + * @throws {Error} When `versioning` is a value the changelogger does not know. + * + * @returns {{strategy: 'stellarwp'|'semver'|'custom', versioning: string, configured: boolean}} + * The strategy, the `versioning` value it came from, and whether package.json has a + * `changelogger` section at all. + */ +function readChangeloggerStrategy(text) { + let section = null + + if (text !== null) { + try { + section = JSON.parse(text)?.changelogger ?? null + } catch { + section = null + } + } + + if (!section) { + return { strategy: 'semver', versioning: 'semver', configured: false } + } + + const versioning = (typeof section === 'object' ? section.versioning : undefined) ?? 'semver' + + if (versioning === 'stellarwp' || versioning === 'semver') { + return { strategy: versioning, versioning, configured: true } + } + + if (typeof versioning === 'string' && /\.(js|ts)$/.test(versioning)) { + return { strategy: 'custom', versioning, configured: true } + } + + throw new Error( + `package.json sets changelogger.versioning to '${versioning}', which is not a versioning ` + + 'strategy the changelogger knows: use stellarwp, semver, or a .js or .ts file. The changelog ' + + 'step would fail on it. Nothing was changed.' + ) +} + +/** + * Where a pup release's phar is published. + * + * @param {string} version A pup release number, already validated. + * + * @returns {string} The download URL. + */ +function pupDownloadUrl(version) { + return `https://github.com/stellarwp/pup/releases/download/${version}/pup.phar` +} + +/** + * The page of a pup release, which answers 404 when the release does not exist. + * + * @param {string} version A pup release number, already validated. + * + * @returns {string} The release page URL. + */ +function pupReleaseUrl(version) { + return `https://github.com/stellarwp/pup/releases/tag/${version}` +} + +/** + * Sends a HEAD request and reports whether the URL exists. + * + * @param {object} exec The @actions/exec toolkit. + * @param {string} url The URL to check. + * + * @returns {Promise<{exists: boolean, missing: boolean, result: object}>} Whether it answered + * 200, whether it answered 404, and curl's result for anything else. + */ +async function headExists(exec, url) { + const result = await exec.getExecOutput('curl', ['-fsSI', url], { + ignoreReturnCode: true, + silent: true, + }) + + // curl -f answers a 404 with exit code 22 and the status in stderr. + return { + exists: result.exitCode === 0, + missing: result.exitCode === 22 && /404/.test(result.stderr), + result, + } +} + +/** + * Which distribution a pup release carries, decided by looking at the release itself: a `pup.phar` + * asset means the PHP build the action runs. A release without one (1.2.3 and 0.0.1 among the + * published ones, and the TypeScript line once it ships) cannot be run from here yet. + * + * The checks are HEAD requests against the asset URL and, when that is missing, the release page, + * not the releases API, so a pinned `pup-version` stays free of API calls and rate limits. The + * second request tells a release without a phar from a release that does not exist, which a + * mistyped pin produces. + * + * @param {object} exec The @actions/exec toolkit. + * @param {string} release The resolved pup release tag. + * + * @throws {Error} When the release does not exist, or a check itself fails and the run cannot + * tell which path to take. + * + * @returns {Promise<'phar'|'ts'>} The distribution the release carries: `ts` for any release + * without a phar, the old ones included, since that is the path they take. + */ +async function resolvePupDistribution({ exec, release }) { + const asset = await headExists(exec, pupDownloadUrl(release)) + + if (asset.exists) { + return 'phar' + } + + if (asset.missing) { + const page = await headExists(exec, pupReleaseUrl(release)) + + if (page.exists) { + return 'ts' + } + + if (page.missing) { + throw new Error(`pup ${release} is not a release: check pup-version for a typo. Nothing was changed.`) + } + + throw checkFailed(release, page.result) + } + + throw checkFailed(release, asset.result) +} + +/** + * The error for a release check that neither succeeded nor answered 404. + * + * @param {string} release The pup release tag. + * @param {object} result What curl returned. + * + * @returns {Error} The error, holding what curl printed. + */ +function checkFailed(release, result) { + const output = [result.stdout, result.stderr].map((text) => text.trim()).filter(Boolean) + + return new Error( + `Checking the pup ${release} release failed (exit code ${result.exitCode}):\n` + output.join('\n') + ) +} + +/** + * The pup commands that prepare a version, in the order they run. + * + * @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] }, + ] +} + +/** + * Reads the changes `git diff --cached --raw --no-renames -z` lists. + * + * Each entry is `: `, NUL, the path, NUL. The + * raw format carries the modes, which the tree entries need and `--name-status` drops, and the + * staged blob's sha, which the upload reads the content from; `--no-abbrev` keeps that sha full. + * `--no-renames` keeps a rename as its deletion and addition, since the tree takes one entry per + * path; `-z` keeps a path with a space or a quote intact. + * + * @param {string} output What git printed. + * + * @returns {{path: string, status: string, mode: string, previousMode: string, blob: string}[]} + * The changes, in the order git lists them. + */ +function parseRawDiff(output) { + const fields = String(output ?? '').split('\0') + const changes = [] + + for (let i = 0; i + 1 < fields.length; i += 2) { + const header = fields[i] + if (!header.startsWith(':')) { + continue + } + + const [previousMode, mode, , blob, status] = header.slice(1).split(' ') + changes.push({ path: fields[i + 1], status, mode, previousMode, blob }) + } + + return changes +} + +/** + * The message of the commit the action creates. + * + * @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) { + return numericPrefix(lastLine(output)) +} + +/** + * The numeric start of a version: 4.17.0 for 4.17.0-beta.1, the whole of 4.17.0.1. + * + * @param {string} version A version. + * + * @returns {string|null} The numeric start, or null when the version does not start with a number. + */ +function numericPrefix(version) { + const match = NUMERIC_PREFIX.exec(version) + + return match ? match[0] : null +} + +/** + * 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 with a changelogger versioning + * the changelogger does not know, or a version lower than the one the branch already has, fails + * with the branch untouched. The version is checked the way the changelog step will check it: by + * the versioning strategy package.json configures. + * + * @param {object} core The @actions/core toolkit, for the log and the outputs. + * @param {object} exec The @actions/exec toolkit. + * @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 or the changelogger versioning is unusable, the + * pup release does not exist, or the download or pup fails. + * + * @returns {Promise} Resolves once pup has run and the outputs are set. + */ +async function runPrepare({ core, exec, env, cwd = process.cwd(), fetchImpl = globalThis.fetch }) { + const changelogger = readChangeloggerStrategy(readCheckoutFile(cwd, 'package.json')) + const version = validateVersion(env.INPUT_VERSION, changelogger.strategy) + const date = resolveDate(env.INPUT_DATE) + const branch = validateBranch(env.INPUT_BRANCH) + const pupVersion = validatePupVersion(env.INPUT_PUP_VERSION) + + 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 commit's parent is the checkout's HEAD and it lands on refs/heads/. From any other + // branch that the release branch is an ancestor of, GitHub would accept it as a fast-forward and + // carry that branch's commits into the release, so the checkout has to be on the release branch. + const head = await exec.getExecOutput('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], { + cwd, + ignoreReturnCode: true, + 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 (!changelogger.configured) { + core.warning( + 'package.json has no changelogger section, so the changelog step uses the changelogger ' + + 'defaults: a keepachangelog changelog.md with semver versioning.' + ) + } else if (changelogger.strategy === 'custom') { + core.warning( + `package.json names a custom versioning strategy (${changelogger.versioning}), so the ` + + "action did not check the version's shape. The changelog step validates it with that strategy." + ) + } + + const pupRelease = await resolvePupRelease({ + 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 nothing can call it yet: a composite action's + // `uses:` references resolve when the JOB is set up, not when the step runs, so the manifest + // cannot carry its action behind a condition, and calling it straight from the script needs + // a published distribution first. Until one exists, a phar-less release fails here with an + // error naming the pin to use instead. The branch-version check in runPrepareWithPhar is + // part of the phar path — the TS contract does not define it yet. + throw new Error( + `pup ${pupRelease} publishes no pup.phar asset, so this action cannot run it. Pin ` + + 'pup-version to a release that ships one, such as 2.0.0. Nothing was changed.' + ) + } + + 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) + // Only the numeric start is compared: 4.17.0-beta.1 counts as 4.17.0, which compareVersions + // reads; a pre-release of the version the branch holds is still that version. + const numeric = numericPrefix(version) + + if (current === null) { + core.warning( + `The version files hold \`${lastLine(printed)}\`, which is not a numeric version, so ` + + `${version} was not checked against it.` + ) + } else if (numeric === null) { + core.warning( + `${version} holds no numeric version, so it was not checked against the ${current} the ` + + 'branch has.' + ) + } else if (compareVersions(numeric, current) < 0) { + throw new Error( + `Refusing to prepare ${version}: ${branch} already has ${current}, a later version. Check the ` + + 'version and the release branch. Nothing was changed.' + ) + } + + for (const { command, args } of pupCommands(phar, version)) { + await runOrFail(exec, command, args, { cwd }, `pup ${args[1]}`) + } +} + +/** + * Commits whatever the preparation changed to the release branch, through the GitHub API. + * + * The changes are listed with git, then sent as blobs and a tree built on the checkout's own tree, + * a commit whose parent is the checkout's own head, and a ref update that is not forced: when the + * branch moved after the checkout, GitHub refuses the update as it would refuse a push, and the + * checkout's files are never written over newer ones. The checkout itself is left as it was: its + * HEAD stays on the commit it had, with the changes in the working tree. + * + * When nothing changed, which is what re-preparing a version that is already on the branch finds, + * it calls nothing and succeeds. + * + * @param {object} github An Octokit, as actions/github-script supplies it, authenticated with the + * action's token. + * @param {object} core The @actions/core toolkit, for the log, the outputs and the summary. + * @param {object} exec The @actions/exec toolkit. + * @param {object} env The environment the action manifest put its inputs in. + * @param {string} cwd The checkout, the working directory by default. + * + * @throws {Error} When a git command or an API call fails, a change is not a regular file, or + * GitHub refuses the ref update. + * + * @returns {Promise} Resolves once the commit is on the branch, or found unnecessary. + */ +async function runCommit({ github, core, exec, env, cwd = process.cwd() }) { + const branch = validateBranch(env.INPUT_BRANCH) + // The prepare step validated the version against the changelogger strategy; its output is + // what the manifest hands this step, so only an empty value is checked here. + const version = readVersion(env.INPUT_VERSION) + const message = commitMessage(branch, version) + const [owner, repo] = String(env.GITHUB_REPOSITORY || '').split('/') + + if (!owner || !repo) { + throw new Error( + 'GITHUB_REPOSITORY is not set, so the action does not know which repository to commit to. ' + + 'Nothing was committed.' + ) + } + + await runOrFail(exec, 'git', ['add', '--all'], { cwd }, 'Staging the changes') + + const raw = await runOrFail( + exec, + 'git', + ['diff', '--cached', '--raw', '--no-renames', '--no-abbrev', '-z'], + { cwd, silent: true }, + 'Listing the changes' + ) + const changes = parseRawDiff(raw) + const files = changes.map((change) => change.path) + + if (changes.length === 0) { + core.info(`Nothing changed: ${branch} already holds ${version}. Nothing was committed.`) + core.setOutput('commit', '') + core.setOutput('files', '') + await core.summary + .addRaw(`Nothing to commit: \`${branch}\` already holds ${version}.`, true) + .write() + + return + } + + for (const change of changes) { + if (change.status !== 'D' && !BLOB_MODES.has(change.mode)) { + throw new Error( + `${change.path} has mode ${change.mode}, which this action does not commit: only regular ` + + 'files are uploaded. Nothing was committed.' + ) + } + } + + const [head, baseTree] = ( + await runOrFail( + exec, + 'git', + ['rev-parse', 'HEAD', 'HEAD^{tree}'], + { cwd, silent: true }, + 'Reading the checkout head' + ) + ) + .trim() + .split('\n') + + const tree = [] + for (const change of changes) { + if (change.status === 'D') { + tree.push({ path: change.path, mode: change.previousMode, type: 'blob', sha: null }) + continue + } + + // The content comes from the staged blob, not the file on disk: what git staged is what a + // local commit would carry, and filters (.gitattributes normalization, an LFS pointer) can + // make the two differ. cat-file reads the blob as bytes, so a binary arrives byte for byte. + const { data: blob } = await github.rest.git.createBlob({ + owner, + repo, + content: execFileSync('git', ['cat-file', 'blob', change.blob], { cwd, maxBuffer: Infinity }).toString('base64'), + encoding: 'base64', + }) + tree.push({ path: change.path, mode: change.mode, type: 'blob', sha: blob.sha }) + } + + const { data: newTree } = await github.rest.git.createTree({ owner, repo, base_tree: baseTree, tree }) + // No author or committer: the commit belongs to the token's identity, github-actions[bot] for the + // default token, and GitHub signs it. + const { data: commit } = await github.rest.git.createCommit({ + owner, + repo, + message, + tree: newTree.sha, + parents: [head], + }) + + try { + await github.rest.git.updateRef({ owner, repo, ref: `heads/${branch}`, sha: commit.sha }) + } catch (error) { + if (error.status === 422) { + throw new Error( + `GitHub refused to update ${branch} (${error.message}): the branch moved after the ` + + 'checkout, or no longer exists. Run the step again on a fresh checkout. Nothing was ' + + 'committed to the branch.' + ) + } + + throw error + } + + core.info(`Committed ${commit.sha} to ${branch}.`) + core.setOutput('commit', commit.sha) + core.setOutput('files', files.join('\n')) + // A Markdown list rather than addList, whose HTML list would show the backticks literally. + await core.summary + .addRaw(`Committed \`${commit.sha.slice(0, 7)}\` "${message}" to \`${branch}\`:\n\n`) + .addRaw(files.map((file) => `- \`${file}\``).join('\n'), true) + .write() +} + +module.exports = { + runPrepare, + runCommit, + readVersion, + validateVersion, + resolveDate, + validateBranch, + validatePupVersion, + resolvePupRelease, + resolvePupDistribution, + readPuprc, + readChangeloggerStrategy, + pupDownloadUrl, + pupReleaseUrl, + pupCommands, + parseRawDiff, + commitMessage, + compareVersions, + readCurrentVersion, + STELLARWP_VERSION_PATTERN, + SEMVER_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..9b3ba2c 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 to the release branch through the GitHub API. A caller to copy is in `templates/workflows/release-prep.yml`. - [ ] Turn every hard-coded repo name, slug, or branch pattern into an input. - [ ] Self-checks: actionlint everything, unit test anything with real logic. - [ ] Tag `v1.0.0`. @@ -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..dc5386f --- /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 to the branch through the GitHub API. No pull request is opened, so the +# change is reviewed wherever the release is. +# +# A file to copy into .github/workflows/ of a plugin repository, not a workflow to call. The +# 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 commits to a branch. Start it by hand with +# `gh workflow run release-prep.yml -f branch=release/M26.jigglypuff -f version=4.17.0`. + +on: + workflow_dispatch: + inputs: + branch: + description: 'Release branch to prepare and commit 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 +# commit would be refused as not a fast-forward. One group per branch makes the second wait for the first. +concurrency: + group: ${{ github.workflow }}-${{ inputs.branch }} + +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 commits the preparation to the branch + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.branch }} + # The action commits through the API with the token it is given, so the checkout keeps + # no credentials in .git/config for a later step to find. + persist-credentials: false + + # A changelog writing strategy that has to be built first, or a toolchain the repository needs, + # 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..247c54c --- /dev/null +++ b/tests/actions/release-prep/release-prep.test.js @@ -0,0 +1,1159 @@ +'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, + readChangeloggerStrategy, + readVersion, + pupDownloadUrl, + pupReleaseUrl, + pupCommands, + parseRawDiff, + commitMessage, + compareVersions, + readCurrentVersion, +} = require('../../../.github/actions/release-prep/release-prep.js') +const { coreWithOutputs, recordingExec, octokitFor } = 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() +} + +/** The bytes of a small binary file, which a text upload would corrupt. */ +const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff]) + +/** + * A checkout of a release branch: one commit holding a version file, an executable, a binary and a + * pending changelog entry. + * + * The commit step runs real git against it to list the changes, and sends the commit itself to an + * Octokit whose network is a stand-in, so a test asserts the requests the API would have received. + * + * @param {string} branch The release branch the checkout is on. + * + * @returns {string} The checkout. + */ +function checkoutOn(branch) { + const work = fs.mkdtempSync(path.join(os.tmpdir(), 'release-prep-git-')) + + git(work, 'init', '--quiet', '-b', branch) + git(work, 'config', 'user.name', 'Test') + git(work, 'config', 'user.email', 'test@example.com') + fs.mkdirSync(path.join(work, 'bin')) + fs.mkdirSync(path.join(work, 'changelog')) + fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.16.0\n') + fs.writeFileSync(path.join(work, 'bin', 'tool.sh'), '#!/bin/sh\n', { mode: 0o755 }) + fs.writeFileSync(path.join(work, 'logo.png'), PNG_BYTES) + fs.writeFileSync(path.join(work, 'changelog', 'entry.yaml'), 'type: fix\n') + git(work, 'add', '-A') + git(work, 'commit', '--quiet', '-m', 'Initial') + + return work +} + +/** The repository paths the commit step writes, as a real Octokit spells them. */ +const API = { + blobs: '/repos/stellarwp/plugin/git/blobs', + trees: '/repos/stellarwp/plugin/git/trees', + commits: '/repos/stellarwp/plugin/git/commits', + ref: '/repos/stellarwp/plugin/git/refs/heads%2Frelease%2FM26.jigglypuff', +} + +/** + * Rules under which every API write succeeds, each blob getting a sha of its own. + * + * @param {object[]} before Rules that take precedence, to make one write fail. + * + * @returns {object[]} Rules for recordingFetch. + */ +function apiAccepts(before = []) { + return [ + ...before, + { + method: 'POST', + path: API.blobs, + responses: [1, 2, 3, 4, 5].map((n) => ({ body: { sha: `blob-${n}` } })), + }, + { method: 'POST', path: API.trees, body: { sha: 'tree-new' } }, + { method: 'POST', path: API.commits, body: { sha: 'commit-new' } }, + { method: 'PATCH', path: API.ref, body: { object: { sha: 'commit-new' } } }, + ] +} + +/** + * Each request as `METHOD path`, for asserting the order the commit step worked in. + * + * @param {object[]} requests The requests recordingFetch recorded. + * + * @returns {string[]} One line per request. + */ +function requested(requests) { + return requests.map((request) => `${request.method} ${request.path}`) +} + +/** + * 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', + GITHUB_REPOSITORY: 'stellarwp/plugin', + ...overrides, + } +} + +describe('release-prep', () => { + describe('validateVersion', () => { + it('accepts three or four numeric parts under the stellarwp strategy', () => { + assert.equal(validateVersion('4.17.0', 'stellarwp'), '4.17.0') + assert.equal(validateVersion('4.17.0.1', 'stellarwp'), '4.17.0.1') + }) + + it('reads the version without surrounding whitespace', () => { + assert.equal(validateVersion(' 4.17.0\n', 'stellarwp'), '4.17.0') + assert.equal(readVersion(' 4.17.0\n'), '4.17.0') + }) + + it('refuses an empty version, saying the run was dispatched without one', () => { + // A release dispatcher that has no version for the release sends an empty one. + for (const empty of ['', ' ', undefined]) { + for (const strategy of ['stellarwp', 'semver', 'custom']) { + assert.throws(() => validateVersion(empty, strategy), /No version was given.*Nothing was changed/s) + } + assert.throws(() => readVersion(empty), /No version was given.*Nothing was changed/s) + } + }) + + it('refuses a pre-release under stellarwp, which that versioning rejects', () => { + assert.throws( + () => validateVersion('4.17.0-beta.1', 'stellarwp'), + /'4\.17\.0-beta\.1' is not a version this action can prepare.*pre-release/s + ) + }) + + it('refuses anything but three or four numeric parts under stellarwp', () => { + // 2.1..21 matters most: the changelogger reads the empty part as 0 and would accept it. + for (const version of ['4.17', '4.17.0.1.2', '2.1..21', 'v4.17.0', '4.17.x', '4.17.0.']) { + assert.throws(() => validateVersion(version, 'stellarwp'), /not a version this action can prepare/) + } + }) + + it('accepts a semver version, pre-release and build included, under semver', () => { + for (const version of ['4.17.0', '4.17.0-beta.1', '4.17.0-rc.1+build.5', '0.1.0']) { + assert.equal(validateVersion(version, 'semver'), version) + } + }) + + it('refuses a fourth part, a leading v and an empty part under semver', () => { + // The semver package behind the changelogger takes v4.17.0; the version goes into the + // version files and the tag as given, so the action does not. + for (const version of ['4.17.0.1', 'v4.17.0', '2.1..21', '04.17.0', '4.17', '4.17.0-']) { + assert.throws(() => validateVersion(version, 'semver'), /not a version this action can prepare.*semver/s) + } + }) + + it('checks only that the version is not empty under a custom strategy', () => { + for (const version of ['M26.1', '4.17.0.1', '4.17.0-beta.1']) { + assert.equal(validateVersion(version, 'custom'), version) + } + }) + }) + + 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('readChangeloggerStrategy', () => { + it('reads the strategy the changelogger section configures', () => { + assert.deepEqual(readChangeloggerStrategy(JSON.stringify(PACKAGE_JSON)), { + strategy: 'stellarwp', + versioning: 'stellarwp', + configured: true, + }) + assert.deepEqual(readChangeloggerStrategy('{"changelogger":{"versioning":"semver"}}'), { + strategy: 'semver', + versioning: 'semver', + configured: true, + }) + }) + + it('falls back to semver without a section, a file or valid JSON, as the changelogger does', () => { + for (const text of ['{"name":"x"}', null, 'not json']) { + assert.deepEqual(readChangeloggerStrategy(text), { + strategy: 'semver', + versioning: 'semver', + configured: false, + }) + } + }) + + it('reads a section without a versioning key as semver, the changelogger default', () => { + assert.deepEqual(readChangeloggerStrategy('{"changelogger":{"changesDir":"changelog"}}'), { + strategy: 'semver', + versioning: 'semver', + configured: true, + }) + }) + + it('reads a .js or .ts file as a custom strategy', () => { + for (const file of ['bin/versioning.js', 'bin/versioning.ts']) { + assert.deepEqual(readChangeloggerStrategy(`{"changelogger":{"versioning":"${file}"}}`), { + strategy: 'custom', + versioning: file, + configured: true, + }) + } + }) + + it('refuses a strategy the changelogger does not know, which would fail the changelog step', () => { + for (const versioning of ['foo', '']) { + assert.throws( + () => readChangeloggerStrategy(`{"changelogger":{"versioning":"${versioning}"}}`), + /is not a versioning strategy the changelogger knows.*Nothing was changed/s + ) + } + }) + }) + + 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('checks the release page when the phar is missing', () => { + assert.equal(pupReleaseUrl('2.0.0'), 'https://github.com/stellarwp/pup/releases/tag/2.0.0') + }) + + it('bumps the version files, then replaces the TBDs', () => { + assert.deepEqual(pupCommands('/tmp/pup.phar', '4.17.0'), [ + { command: 'php', args: ['/tmp/pup.phar', 'replace-version', '4.17.0'] }, + { command: 'php', args: ['/tmp/pup.phar', 'replace-tbd', '4.17.0'] }, + ]) + }) + }) + + describe('parseRawDiff', () => { + it('reads each change with its modes, status, blob and path from the NUL-separated raw diff', () => { + const raw = + ':100644 100644 1111111 2222222 M\0plugin.php\0' + + ':000000 100644 0000000 3333333 A\0changelog.txt\0' + + ':100644 000000 4444444 0000000 D\0changelog/entry.yaml\0' + + ':100644 100755 5555555 5555555 M\0bin/tool.sh\0' + + assert.deepEqual(parseRawDiff(raw), [ + { path: 'plugin.php', status: 'M', mode: '100644', previousMode: '100644', blob: '2222222' }, + { path: 'changelog.txt', status: 'A', mode: '100644', previousMode: '000000', blob: '3333333' }, + { path: 'changelog/entry.yaml', status: 'D', mode: '000000', previousMode: '100644', blob: '0000000' }, + { path: 'bin/tool.sh', status: 'M', mode: '100755', previousMode: '100644', blob: '5555555' }, + ]) + }) + + it('reads an empty diff as no changes', () => { + assert.deepEqual(parseRawDiff(''), []) + }) + }) + + 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 commit's parent is the checkout's HEAD. From another branch that the release branch is + // an ancestor of, the ref update would be a fast-forward and carry that branch's commits along. + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([{ command: 'git', args: /^symbolic-ref /, stdout: 'main\n' }]) + ) + 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('validates the version with the strategy package.json configures', async (t) => { + const semver = { changelogger: { versioning: 'semver', changesDir: 'changelog' } } + + const accepted = await coreWithOutputs(t) + const run = recordingExec(allSucceed()) + await runPrepare({ + core: accepted.core, + exec: run.exec, + env: prepareEnv({ INPUT_VERSION: '4.17.0-beta.1' }), + cwd: checkoutWith({ '.puprc': PUPRC, 'package.json': semver }), + }) + assert.ok(sequence(run.calls).some((call) => call.endsWith('replace-version 4.17.0-beta.1'))) + assert.equal(accepted.outputs().version, '4.17.0-beta.1') + + const refused = await coreWithOutputs(t) + const stopped = recordingExec(allSucceed()) + await assert.rejects( + runPrepare({ + core: refused.core, + exec: stopped.exec, + env: prepareEnv({ INPUT_VERSION: '4.17.0.1' }), + cwd: checkoutWith({ '.puprc': PUPRC, 'package.json': semver }), + }), + /'4\.17\.0\.1' is not a version this action can prepare.*semver/s + ) + assert.deepEqual(stopped.calls, [], 'nothing ran for a version the changelog step would refuse') + }) + + it('leaves the shape check to a custom strategy, and says so', async (t) => { + const { core, logged } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const custom = { changelogger: { versioning: 'bin/versioning.js' } } + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': custom }) + + await runPrepare({ core, exec, env: prepareEnv({ INPUT_VERSION: 'M26.1' }), cwd }) + + assert.match(logged(), /::warning::.*custom versioning strategy \(bin\/versioning\.js\)/) + assert.match(logged(), /::warning::.*M26\.1 holds no numeric version/) + assert.ok(sequence(calls).some((call) => call.endsWith('replace-version M26.1'))) + }) + + it('stops before running anything when package.json names a strategy the changelogger does not know', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec(allSucceed()) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': { changelogger: { versioning: 'foo' } } }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv(), cwd }), + /changelogger\.versioning to 'foo', which is not a versioning strategy the changelogger knows/ + ) + assert.deepEqual(calls, []) + assert.deepEqual(outputs(), {}) + }) + + it('compares a pre-release by its numeric start against the branch', async (t) => { + const semver = { changelogger: { versioning: 'semver' } } + + for (const [current, accepted] of [ + ['4.18.0', false], + ['4.17.0', true], + ]) { + const { core } = await coreWithOutputs(t) + const { exec } = recordingExec( + allSucceed([{ command: 'php', args: /get-version$/, stdout: `${current}\n` }]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': semver }) + const prepare = runPrepare({ core, exec, env: prepareEnv({ INPUT_VERSION: '4.17.0-beta.1' }), cwd }) + + if (accepted) { + await prepare + } else { + await assert.rejects(prepare, /Refusing to prepare 4\.17\.0-beta\.1: .* already has 4\.18\.0/) + } + } + }) + + it('fails with pup output when pup fails, and stops there', async (t) => { + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + 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 exists but has no phar, pointing at the pin', async (t) => { + // pup 1.2.3 is such a release today; the TypeScript line will be another. + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([ + { command: 'curl', args: /fsSI .*\/download\//, exitCode: 22, stderr: 'The requested URL returned error: 404' }, + { command: 'curl', args: /fsSI .*\/releases\/tag\// }, + ]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv(), cwd }), + /pup 2\.0\.0 publishes no pup\.phar asset.*Pin pup-version to a release that ships one, such as 2\.0\.0/s + ) + assert.deepEqual(outputs(), { distribution: 'ts' }, 'the resolved distribution is reported on the failed run') + assert.deepEqual( + sequence(calls).filter((call) => call.startsWith('curl')), + [ + 'curl -fsSI https://github.com/stellarwp/pup/releases/download/2.0.0/pup.phar', + 'curl -fsSI https://github.com/stellarwp/pup/releases/tag/2.0.0', + ], + 'the release page is checked once the asset is missing' + ) + assert.ok( + !sequence(calls).some((call) => /fsSL|php /.test(call)), + 'no phar is downloaded and no PHP runs without a phar asset' + ) + }) + + it('fails when the pinned release does not exist, naming the pin', async (t) => { + // A typo in pup-version gets a 404 on the asset too; the release page tells the two apart. + const { core, outputs } = await coreWithOutputs(t) + const { exec, calls } = recordingExec( + allSucceed([ + { command: 'curl', args: /fsSI/, exitCode: 22, stderr: 'The requested URL returned error: 404' }, + ]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv({ INPUT_PUP_VERSION: '2.0.9' }), cwd }), + /pup 2\.0\.9 is not a release: check pup-version for a typo\. Nothing was changed\./ + ) + assert.deepEqual(outputs(), {}, 'no distribution is reported for a release that does not exist') + assert.ok(!sequence(calls).some((call) => /fsSL|php /.test(call))) + }) + + it('fails loudly when the release page check itself errors', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec } = recordingExec( + allSucceed([ + { command: 'curl', args: /fsSI .*\/download\//, exitCode: 22, stderr: 'The requested URL returned error: 404' }, + { command: 'curl', args: /fsSI .*\/releases\/tag\//, exitCode: 28, stderr: 'Connection timed out' }, + ]) + ) + const cwd = checkoutWith({ '.puprc': PUPRC, 'package.json': PACKAGE_JSON }) + + await assert.rejects( + runPrepare({ core, exec, env: prepareEnv(), cwd }), + /Checking the pup 2\.0\.0 release failed.*Connection timed out/s + ) + }) + + it('fails loudly when the phar check itself errors', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec } = recordingExec( + 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 to the branch through the API, on the checkout's own head", async (t) => { + const { core, outputs, summary } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + const { github, requests } = await octokitFor(apiAccepts()) + const work = checkoutOn('release/M26.jigglypuff') + const head = git(work, 'rev-parse', 'HEAD') + const baseTree = git(work, 'rev-parse', 'HEAD^{tree}') + fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.17.0\n') + fs.writeFileSync(path.join(work, 'changelog.txt'), '= 4.17.0 =\n') + fs.unlinkSync(path.join(work, 'changelog', 'entry.yaml')) + fs.writeFileSync(path.join(work, 'bin', 'tool.sh'), '#!/bin/sh\necho\n', { mode: 0o755 }) + fs.writeFileSync(path.join(work, 'logo.png'), Buffer.concat([PNG_BYTES, Buffer.from([0x01])])) + + await runCommit({ github, core, exec, env: commitEnv(), cwd: work }) + + assert.deepEqual(requested(requests), [ + `POST ${API.blobs}`, + `POST ${API.blobs}`, + `POST ${API.blobs}`, + `POST ${API.blobs}`, + `POST ${API.trees}`, + `POST ${API.commits}`, + `PATCH ${API.ref}`, + ]) + + const blobs = requests.filter((request) => request.path === API.blobs).map((request) => request.body) + assert.ok( + blobs.every((blob) => blob.encoding === 'base64'), + 'every file is uploaded as base64, so a binary survives' + ) + const logo = Buffer.concat([PNG_BYTES, Buffer.from([0x01])]).toString('base64') + assert.ok(blobs.some((blob) => blob.content === logo), 'the binary is uploaded byte for byte') + + const tree = requests.find((request) => request.path === API.trees).body + assert.equal(tree.base_tree, baseTree, "the tree builds on the checkout's own tree") + assert.deepEqual(tree.tree, [ + { path: 'bin/tool.sh', mode: '100755', type: 'blob', sha: 'blob-1' }, + { path: 'changelog.txt', mode: '100644', type: 'blob', sha: 'blob-2' }, + { path: 'changelog/entry.yaml', mode: '100644', type: 'blob', sha: null }, + { path: 'logo.png', mode: '100644', type: 'blob', sha: 'blob-3' }, + { path: 'plugin.php', mode: '100644', type: 'blob', sha: 'blob-4' }, + ]) + + const commit = requests.find((request) => request.path === API.commits).body + assert.deepEqual(commit, { + message: 'Prepare release/M26.jigglypuff (4.17.0)', + tree: 'tree-new', + parents: [head], + }) + + const ref = requests.find((request) => request.path === API.ref).body + assert.deepEqual(ref, { sha: 'commit-new' }, 'the ref update is not forced') + + assert.deepEqual(outputs(), { + commit: 'commit-new', + files: 'bin/tool.sh\nchangelog.txt\nchangelog/entry.yaml\nlogo.png\nplugin.php', + }) + assert.match(summary(), /Committed `commit-` "Prepare release\/M26\.jigglypuff \(4\.17\.0\)" to `release\/M26\.jigglypuff`/) + assert.match(summary(), /^- `bin\/tool\.sh`\n- `changelog\.txt`\n- `changelog\/entry\.yaml`\n- `logo\.png`\n- `plugin\.php`$/m) + assert.equal(git(work, 'rev-parse', 'HEAD'), head, 'the checkout itself stays where it was') + }) + + it('uploads the staged content, not the file on disk', async (t) => { + // .gitattributes normalization (here CRLF to LF) makes the staged blob differ from what is + // on the disk; the commit has to carry what git staged. + const { core } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + const { github, requests } = await octokitFor(apiAccepts()) + const work = checkoutOn('release/M26.jigglypuff') + fs.writeFileSync(path.join(work, '.gitattributes'), '* text eol=lf\n') + fs.writeFileSync(path.join(work, 'notes.txt'), 'first line\r\nsecond line\r\n') + + await runCommit({ github, core, exec, env: commitEnv(), cwd: work }) + + const staged = requests + .filter((request) => request.path === API.blobs) + .map((request) => request.body) + .find((blob) => Buffer.from(blob.content, 'base64').toString() === 'first line\nsecond line\n') + assert.ok(staged, 'the blob is the staged content, with CRLF normalized to LF') + }) + + it('uploads a file larger than the exec output buffer', async (t) => { + // execFileSync caps stdout at 1 MiB by default; a long changelog or readme passes that. + const { core } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + const { github, requests } = await octokitFor(apiAccepts()) + const work = checkoutOn('release/M26.jigglypuff') + const big = `${'line of the long changelog\n'.repeat(45000)}tail\n` + fs.writeFileSync(path.join(work, 'changelog.txt'), big) + + await runCommit({ github, core, exec, env: commitEnv(), cwd: work }) + + const blob = requests.find((request) => request.path === API.blobs).body + assert.equal(Buffer.from(blob.content, 'base64').toString(), big, 'the whole file uploads') + }) + + it('neither commits nor calls the API when nothing changed, and still succeeds', async (t) => { + // A retry of the same version finds the branch already prepared. + const { core, outputs } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + const { github, requests } = await octokitFor(apiAccepts()) + const work = checkoutOn('release/M26.jigglypuff') + + await runCommit({ github, core, exec, env: commitEnv(), cwd: work }) + + assert.deepEqual(requests, []) + assert.deepEqual(outputs(), { commit: '', files: '' }) + }) + + it('fails when the branch moved after the checkout, without forcing the ref', async (t) => { + // GitHub refuses a ref update that is not a fast-forward with 422, as a push would. + const { core } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + const { github, requests } = await octokitFor( + apiAccepts([{ method: 'PATCH', path: API.ref, status: 422, body: { message: 'Update is not a fast forward' } }]) + ) + const work = checkoutOn('release/M26.jigglypuff') + fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.17.0\n') + + await assert.rejects( + runCommit({ github, core, exec, env: commitEnv(), cwd: work }), + /GitHub refused to update release\/M26\.jigglypuff \(Update is not a fast forward\): the branch moved after the checkout.*Nothing was committed/s + ) + assert.ok(!requests.some((request) => request.body?.force), 'no ref update is forced') + }) + + it('fails with the API error when a write fails', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + const { github } = await octokitFor( + apiAccepts([{ method: 'POST', path: API.trees, status: 500, body: { message: 'boom' } }]) + ) + const work = checkoutOn('release/M26.jigglypuff') + fs.writeFileSync(path.join(work, 'plugin.php'), 'Version: 4.17.0\n') + + await assert.rejects(runCommit({ github, core, exec, env: commitEnv(), cwd: work }), /boom/) + }) + + it('refuses a change that is not a regular file, naming it', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + const { github, requests } = await octokitFor(apiAccepts()) + const work = checkoutOn('release/M26.jigglypuff') + fs.symlinkSync('plugin.php', path.join(work, 'link.php')) + + await assert.rejects( + runCommit({ github, core, exec, env: commitEnv(), cwd: work }), + /link\.php has mode 120000, which this action does not commit/ + ) + assert.deepEqual(requests, [], 'nothing is uploaded') + }) + + it('fails before listing anything when the repository is unknown', async (t) => { + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec([]) + const { github } = await octokitFor(apiAccepts()) + + await assert.rejects( + runCommit({ github, core, exec, env: commitEnv({ GITHUB_REPOSITORY: '' }), cwd: '/nowhere' }), + /GITHUB_REPOSITORY/ + ) + assert.deepEqual(calls, []) + }) + }) +}) 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']]) + }) + }) })