diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md new file mode 100644 index 0000000..2235354 --- /dev/null +++ b/.github/actions/release-merge/README.md @@ -0,0 +1,193 @@ +# release-merge + +Merges a release: the release branch into the base branch, then the base branch back into develop. +**The base branch is `main`, or `master` where that is the repository's name for it**, set with +`base-branch`: + +```yaml +- uses: actions/checkout@v6 + with: + persist-credentials: false + +- uses: stellarwp/plugin-toolbox/.github/actions/release-merge@v1 + id: merge + with: + branch: ${{ inputs.branch }} + version: ${{ inputs.version }} + +# Build the plugin here, then publish the release at ${{ steps.merge.outputs.base-sha }}. +``` + +Publishing the GitHub release is not this action's: the caller does it after its build, at the +commit this action leaves on the base branch (`base-sha`), so the release can carry the build and is +published only once it exists. + +It is the part of a deploy that is the same for every plugin repository, on a base branch plus +`develop` model or on a base branch alone (`develop-branch: ''` skips the merge back). Building the +plugin, uploading it anywhere and announcing it are the caller's steps, after the action, on the +checkout it leaves on the merged base branch. + +## What it does + +In this order, stopping at the first failure: + +1. Checks the inputs, and the version against the repository's tags. See + [Versions it refuses](#versions-it-refuses). +2. Fetches the release branch, the base branch and develop from `origin`, with the full history + when the checkout is shallow. Then it checks that every version file the branch's `.puprc` lists + holds the version and, when the version is already tagged, that the tag contains the branch: a + retry of this release goes on, a tag made from another commit is refused. +3. Merges the release branch into the base branch as a merge commit, `Merge release/… into main` + (or `into master`), and pushes it. +4. Merges the **base branch** into develop as a merge commit, `Merge main back into develop + (release/…)`, and pushes it. The base branch rather than the release branch, so develop + descends from the base and consecutive releases bring one version bump each instead of + divergent copies of the same generated lines. Skipped when `develop-branch` is empty, for a + repository that has no develop branch. +The checkout is left on the base branch at the merged commit, clean, and that commit is the +`base-sha` output: what the caller builds from and tags. The merge commits are +made by `github-actions[bot]`; the pushes are made by the identity of `token`, also +`github-actions[bot]` for the default token. + +## Running it again + +Every step is safe to run again after a late failure, which is what a retry from a release tool +does: + +- A branch that already holds the other merges as "Already up to date" and pushes nothing. +- A version already published counts as a retry only when its tag contains the release branch, + that is, when it was published from this branch. See [Versions it refuses](#versions-it-refuses). + The caller's publish then has nothing to do. +- A conflict resolved by hand on the branch, then pushed, is found as an already-merged branch on + the next run, which goes on to the step that had not run. + +So a run that failed after merging the base, or in a later step of the caller, is rerun as it is; +nothing has to be undone first. + +## When a merge conflicts + +The conflicted merge is abandoned (`git merge --abort`), nothing is pushed for that branch, and the +step fails with the branch named. The branches already pushed stay pushed: a conflict merging the +base into develop leaves the release on the base. Resolve the conflict on the branch by hand, push, +and run the step again. + +The log carries this line, on one line: + +``` +MERGE_CONFLICT: merging main into develop conflicted. Resolve it on develop by hand and push, +then run this step again. Nothing was pushed to develop. +``` + +and the `conflict` output names the branch. The [release +dashboard](https://github.com/stellarwp/nexcess-one) classifies the run from that line: it reads the +marker `MERGE_CONFLICT` at the start of an output line of the job log, every line of which the +runner prefixes with a timestamp. The marker is printed from the script, not written in the +workflow, so it is not in the step's echoed inputs and only appears when a merge did conflict. The +two lines git itself prints, `CONFLICT (…)` and `Automatic merge failed`, are in the log as well. + +## Versions it refuses + +- **Anything but 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` and a leading `v` are refused: the version becomes the tag and the title of the + published release, and a beta or an RC is not released this way. The release dashboard validates + versions with the same rule, so a version it dispatches has passed it already; release-prep + does under the stellarwp changelog versioning. +- **A version the branch does not hold.** The release branch's `.puprc` lists the files that carry the + version under `paths.versions`, with the regex that finds it in each, the same entries release-prep + bumps. The action reads every one of them on the branch and refuses when any holds a different + version, naming the file: the version becomes the tag, so it has to be the one the branch was + prepared with. A branch without a `.puprc`, or with a file where the regex finds nothing, is + refused too, since the version it holds cannot be read. +- **An empty version.** A tool that dispatches the workflow for a release with no version set sends + an empty one. When the workflow declares `version` as required, GitHub refuses the dispatch + itself; when it is optional, the action fails the run before merging. +- **A version whose tag exists without a published release.** Publishing would release whatever + commit the tag points at. Delete the tag or release another version. +- **A version already released from another commit.** When the tag exists with a published release + and does not contain the release branch, the version went out before this branch, and the branch + was never bumped past it: a branch cut from `main` right after a release still carries that + release's version. Merging it would put a second set of changes under a version that is already + out, so the action refuses, with the branches untouched. Give the release a new version and + prepare the branch again. A tag that does contain the branch is a retry of this release, and the + action goes on. + +The branches are checked too: each has to be a name git accepts, the base and develop have to be +two different branches (when there is a develop), and the release branch cannot be either of them. + +## Inputs + +| Input | Required | Default | What it is | +|---|---|---|---| +| `branch` | yes | | The release branch to merge, e.g. `release/M26.jigglypuff`. A branch name, fetched from `origin`. | +| `version` | yes | | The version being released: three or four numeric parts. | +| `base-branch` | no | `main` | The branch the release is merged into: `main`, or `master` where that is the repository's name for it. | +| `develop-branch` | no | `develop` | The branch the base is merged back into. Empty for a repository without one: only the first merge is made. Write `develop-branch: ''` in the caller itself; a `workflow_dispatch` input left empty takes its default, so the empty value cannot be passed through one. | +| `token` | no | `github.token` | The token for the fetch, the pushes and the tag reads. Needs `contents: write`. See [The token](#the-token). | + +## Outputs + +| Output | What it is | +|---|---| +| `version` | The version released. | +| `branch` | The release branch merged. | +| `base-sha` | The commit on the base branch that holds the release: the merge commit, or the base's tip on a retry that found the merge done. The commit to publish the release at; the checkout is left on it. | +| `conflict` | The branch a merge conflicted on, or empty. A conflict fails the step, so read it with `continue-on-error` or in a later `if: failure()` step. | + +## One deploy at a time + +Two releases prepared with the same version, deployed at the same time, would merge side by side: +the second's changes would land on the base under a version the first had just published, and its +publish would be skipped as already done. The action cannot stop that on its own, since both runs +start before either has a tag to find. The caller does, with a `concurrency` group shared by every +deploy of the repository: + +```yaml +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false +``` + +The second run then waits for the first, finds the version tagged from another branch, and is +refused before merging. A repository that also deploys from another workflow gives it the same +group. GitHub keeps one run waiting per group: a third deploy started while one is already waiting +cancels the waiting one, which is started again. (`queue: max` would keep every waiting run, but +actionlint 1.7 does not know the key and the plugin repositories lint their workflows with it.) + +## The token + +The job needs `contents: write`, and the token is used for the fetch, the two pushes and the tag +and release reads. It is handed to git through the environment of those commands only (`GIT_CONFIG_*`, the +`http./.extraheader` header actions/checkout would store), so it is never in the checkout's +`.git/config` and never on a command line in the log. Use `persist-credentials: false` on the +checkout; it matters when later steps of the same job run third-party actions. + +The default `github.token` is enough when the repository's rules allow what the action does: a +direct push to the base branch and to develop. The merges are pushed, not opened as pull requests, +so a base branch that only accepts pull requests refuses the push, with any token that is not on +the rule's bypass list. GiveWP's rules are the reference shape: the branches are protected from +deletion and non-fast-forward pushes, and `github.token` pushes the merges. The human gate is not +the token: the caller puts the job in an environment with required reviewers, and the +release branch reached this step after its review and QA approval. A repository whose rules still +need another actor should pass a GitHub App installation token minted in the run rather than a +personal access token. A release published with the default `github.token` triggers no `release` +workflow; one published with a personal access token or a GitHub App token does. + +## The checkout + +The action runs in the caller's checkout, which can be on any branch and at any depth: the three +branches are fetched explicitly, and a shallow checkout is unshallowed first. Run it on a clean +checkout; it switches branches, and uncommitted changes would travel with it or stop it. + +The checkout's `origin` has to be the repository the run is on: `GITHUB_REPOSITORY` is where the +tags and the release are read and written. + +## How it runs + +One [actions/github-script](https://github.com/actions/github-script) step runs +[`release-merge.js`](./release-merge.js): the checks and the merges. The inputs reach the script +through environment variables, never interpolated into it, so a crafted value is data rather than +part of the program. The merges are git commands in the checkout; the tag and release reads go +through github-script's Octokit. + +The tests, `node --test` from the repository root, run the merges with real git against a bare +repository on disk and the API calls against a recorded client. diff --git a/.github/actions/release-merge/action.yml b/.github/actions/release-merge/action.yml new file mode 100644 index 0000000..f1af470 --- /dev/null +++ b/.github/actions/release-merge/action.yml @@ -0,0 +1,92 @@ +# Merges a release: the release branch into the base branch (`main`, or `master` where that is the +# name), then the base branch back into develop. The part of a deploy that is the same for every +# plugin; what happens after the merge (building, publishing the GitHub release, wordpress.org, +# announcements) is the caller's, at the commit this one leaves on the base (`base-sha`). +# +# The checks and the merges live in release-merge.js, run by actions/github-script, so there is no +# dependency to install and nothing to build. The merges are git in the caller's checkout; the tag +# and release reads go through the GitHub API. +name: Release merge +description: Merges the release branch into the base and the base back into develop + +inputs: + branch: + description: >- + The release branch to merge, e.g. `release/M26.jigglypuff`. A branch name, not any git ref. It is + fetched from `origin`; the caller's checkout can be on any branch and at any depth. + required: true + version: + description: >- + The version being released: three or four numeric parts, such as 4.17.0 or 4.17.0.1, the rule + the release dashboard applies. It becomes the tag and the title of the GitHub release, so it has + to be the version the release branch holds: every file the branch's .puprc lists under + paths.versions is read, and a different version in any of them is refused. A pre-release such as + 4.17.0-beta.1 and an empty value are refused before anything is merged, and so is a version + whose tag already exists, unless that tag's release was published from this branch, which makes + the run a retry. Publishing the release is the caller's, after this action. + required: true + base-branch: + description: 'The branch the release is merged into: `main`, or `master` where that is the name.' + required: false + default: 'main' + develop-branch: + description: >- + The branch the base is merged back into after the release. Pass an empty value for a repository + that has no develop branch: the release is merged into the base and nothing is merged back. Write + it in the caller (`develop-branch: ''`); a workflow_dispatch input left empty takes its default. + required: false + default: 'develop' + token: + description: >- + Token the fetch and the pushes are made with, and the tag and release reads. It needs + `contents: write` on the repository, so the job needs that permission too. It is handed to git + through the environment of the fetch and the pushes only, never written to the checkout's + config, so the checkout can use `persist-credentials: false`. + required: false + default: ${{ github.token }} + +outputs: + version: + description: 'The version released.' + value: ${{ steps.merge.outputs.version }} + branch: + description: 'The release branch merged.' + value: ${{ steps.merge.outputs.branch }} + base-sha: + description: >- + The commit on the base branch that holds the release: the merge commit, or the base's tip on a + retry that found the merge already done. The commit to publish the release at; it is also the + commit the checkout is left on. + value: ${{ steps.merge.outputs.base-sha }} + conflict: + description: >- + Which branch a merge conflicted on, `main` or `develop` for the defaults, or empty when the + merges went through. A conflict fails the step, so a caller reads this with `continue-on-error` + or in a later `if: failure()` step. + value: ${{ steps.merge.outputs.conflict }} + +runs: + using: 'composite' + steps: + - name: Merge the release into the base and the base back into develop + id: merge + 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_BASE_BRANCH: ${{ inputs.base-branch }} + INPUT_DEVELOP_BRANCH: ${{ inputs.develop-branch }} + INPUT_TOKEN: ${{ inputs.token }} + with: + # The tag and release reads go through github-script's own client, built from this token. + github-token: ${{ inputs.token }} + script: | + const { runMerge } = require(`${process.env.ACTION_PATH}/release-merge.js`) + await runMerge({ github, core, exec, env: process.env }) + diff --git a/.github/actions/release-merge/release-merge.js b/.github/actions/release-merge/release-merge.js new file mode 100644 index 0000000..da6dd20 --- /dev/null +++ b/.github/actions/release-merge/release-merge.js @@ -0,0 +1,481 @@ +'use strict' + +/** + * Merges a release: the release branch into the base branch (`main`, or `master` where that is the + * name), then the base branch back into develop. Publishing the GitHub release is the caller's, + * after this action and its build, at the commit this one leaves on the base (`base-sha`). + * + * runMerge checks the inputs and the version against the repository's tags, then does the two + * merges with git in the caller's checkout and pushes them. The functions it is built from are pure, + * apart from the git commands run through `exec` and the API calls, so a test can run them against + * a local remote and assert the requests. + * + * Every step is safe to run again after a late failure: a branch that already holds the release + * merges as a no-op. + */ + +const { validateVersion: validateReleaseVersion } = require('../../lib/versions.js') +const { readRepository, isNotFound, tagExists } = require('../../lib/github.js') +const { runOrFail } = require('../../lib/exec.js') +const { readVersionFiles, versionIn } = require('../../lib/puprc.js') + +/** The identity the merge commits are made with: the one GitHub gives the default token. */ +const BOT_IDENTITY = { + name: 'github-actions[bot]', + email: '41898282+github-actions[bot]@users.noreply.github.com', +} + +/** + * The lines git prints when a merge stops on a conflict: one `CONFLICT (…)` per file, then the + * closing line. The release dashboard reads the same two strings from the job log as its fallback. + */ +const CONFLICT_OUTPUT = /CONFLICT \(|Automatic merge failed/ + +/** + * Reads the version to release: the shared rule, with this action's closing line. + * + * @param {string} version The `version` input. + * + * @throws {Error} When it is empty or not three or four numeric parts. + * + * @returns {string} The version, without surrounding whitespace. + */ +function validateVersion(version) { + return validateReleaseVersion(version, 'Nothing was merged.') +} + +/** + * Checks the shape of a branch input. Whether git accepts the name is checked by git, in runMerge. + * + * @param {string} branch The input value. + * @param {string} input Which input it is, for the message: `branch`, `base-branch` or + * `develop-branch`. + * @param {object} options + * @param {boolean} options.optional Whether an empty value is allowed, for `develop-branch`: a + * repository without a develop branch leaves it empty. + * + * @throws {Error} When it is empty and required, or a full ref. + * + * @returns {string|null} The branch name, without surrounding whitespace; null for an empty + * optional one. + */ +function validateBranch(branch, input, { optional = false } = {}) { + const trimmed = String(branch ?? '').trim() + + if (trimmed === '') { + if (optional) { + return null + } + throw new Error(`No ${input} was given: pass a branch name, such as release/M26.test. Nothing was merged.`) + } + + if (trimmed.startsWith('refs/')) { + throw new Error( + `Refusing ${input} '${trimmed}': pass the branch name, e.g. '${trimmed.replace(/^refs\/heads\//, '')}'. ` + + 'Nothing was merged.' + ) + } + + return trimmed +} + +/** + * The header git sends to authenticate as the token, the same one actions/checkout stores when it + * persists credentials. + * + * @param {string} token The token. + * + * @returns {string} The `AUTHORIZATION: basic …` header. + */ +function authorizationHeader(token) { + return `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}` +} + +/** + * The environment that hands git the token for one command, scoped to the server the run is on. + * + * Git reads `GIT_CONFIG_COUNT` and the numbered key/value pairs as configuration for that process + * only, so the token reaches the fetch and the pushes and nothing else: it is never written to + * .git/config, where a later step of the same job would find it, and it is never on a command line, + * which @actions/exec echoes into the log. + * + * @param {string} token The token. + * @param {string} serverUrl The GitHub server, `https://github.com` or a GitHub Enterprise URL. + * + * @returns {object} The three variables. + */ +function gitAuthEnv(token, serverUrl) { + const server = String(serverUrl || 'https://github.com').replace(/\/+$/, '') + + return { + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: `http.${server}/.extraheader`, + GIT_CONFIG_VALUE_0: authorizationHeader(token), + } +} + +/** + * The message of a merge commit. + * + * @param {string} from The branch merged. + * @param {string} into The branch it was merged into. + * + * @returns {string} The message. + */ +function mergeMessage(from, into) { + return `Merge ${from} into ${into}` +} + +/** + * Whether a failed `git merge` stopped on a conflict, as opposed to failing for any other reason. + * + * @param {{exitCode: number, stdout: string, stderr: string}} result What the merge returned. + * + * @returns {boolean} True for a conflict. + */ +function isConflict(result) { + return result.exitCode !== 0 && CONFLICT_OUTPUT.test(`${result.stdout}\n${result.stderr}`) +} + +/** + * Checks the version against the repository's tags before anything is merged. + * + * A version that was never tagged is the common case. One whose tag exists with a published release + * may be a retry after a late failure; runMerge decides that once the tag is fetched, by checking + * that the tag contains the branch. One whose tag exists without a release would make the publish + * reuse whatever commit the tag points at, so it is refused here, with the base and develop + * untouched. + * + * @param {object} options + * @param {object} options.github The Octokit github-script supplies. + * @param {string} options.owner The repository owner. + * @param {string} options.repo The repository name. + * @param {string} options.version The version, validated. + * + * @throws {Error} When the tag exists without a release, or a read fails for any reason but 404. + * + * @returns {Promise<{tagged: boolean, releaseUrl: string|null}>} Whether the tag exists, and the + * published release's URL when it does. + */ +async function runPreflight({ github, owner, repo, version }) { + if (!(await tagExists({ github, owner, repo, version, what: 'Nothing was merged.' }))) { + return { tagged: false, releaseUrl: null } + } + + try { + const { data: release } = await github.rest.repos.getReleaseByTag({ owner, repo, tag: version }) + + return { tagged: true, releaseUrl: release.html_url } + } catch (error) { + if (isNotFound(error)) { + throw new Error( + `The tag ${version} already exists, but there is no published release for it, so publishing ` + + 'would release whatever commit the tag points at. Delete the tag or release another ' + + 'version. Nothing was merged.' + ) + } + + throw new Error( + `Reading the release for ${version} failed (HTTP ${error.status}): ${error.message}. Nothing was merged.` + ) + } +} + +/** + * Reads a file from a ref of the checkout, without checking it out. + * + * @param {object} exec The @actions/exec toolkit. + * @param {string} cwd The checkout. + * @param {string} ref The ref, e.g. `origin/release/M26.test`. + * @param {string} file The path in the repository. + * + * @returns {Promise} The contents, or null when the ref has no such file. + */ +async function fileAt(exec, cwd, ref, file) { + const result = await exec.getExecOutput('git', ['show', `${ref}:${file}`], { cwd, ignoreReturnCode: true, silent: true }) + + return result.exitCode === 0 ? result.stdout : null +} + +/** + * Checks that the version to release is the one the release branch holds, in every file .puprc lists + * under paths.versions. The tag and the title of the release come from `version`, so a value other + * than what release-prep wrote into the branch would publish the code under the wrong version. + * + * @param {object} options + * @param {object} options.exec The @actions/exec toolkit. + * @param {string} options.cwd The checkout. + * @param {string} options.branch The release branch, fetched as `origin/`. + * @param {string} options.version The version to release, validated. + * + * @throws {Error} When the branch has no usable .puprc, a version file holds no version, or any of + * them holds a different version. + * + * @returns {Promise} Resolves when every version file holds `version`. + */ +async function assertBranchHoldsVersion({ exec, cwd, branch, version }) { + const ref = `origin/${branch}` + const files = readVersionFiles(await fileAt(exec, cwd, ref, '.puprc'), 'Nothing was merged.') + + for (const { file, regex } of files) { + const content = await fileAt(exec, cwd, ref, file) + if (content === null) { + throw new Error(`${file}, which .puprc lists under paths.versions, is not on ${branch}. Nothing was merged.`) + } + + let found + try { + found = versionIn(content, regex) + } catch (error) { + throw new Error( + `The regex .puprc gives for ${file} (${regex}) is not one JavaScript accepts: ${error.message}. ` + + 'Nothing was merged.' + ) + } + if (found === null) { + throw new Error( + `${file} on ${branch} holds no version where .puprc expects one (${regex}). Nothing was merged.` + ) + } + if (found !== version) { + throw new Error( + `${branch} holds ${found} in ${file}, not ${version}. The version to release has to be the one ` + + 'the branch was prepared with; check the version, or prepare the branch again. Nothing was merged.' + ) + } + } +} + +/** + * Runs a git command in the checkout and fails with its output when it exits non-zero. + * + * @param {object} exec The @actions/exec toolkit. + * @param {string[]} args The git arguments. + * @param {object} options Options for @actions/exec, `cwd` among them. + * @param {string} what What the command does, to open the error message. + * + * @throws {Error} When git exits non-zero. + * + * @returns {Promise} What git printed to stdout. + */ +function gitOrFail(exec, args, options, what) { + return runOrFail(exec, 'git', args, options, what) +} + +/** + * Merges one branch into the branch the checkout is on, as a merge commit, and pushes it. + * + * A branch that already holds the other merges as "Already up to date" and pushes nothing, which is + * what a retry after a late failure finds. A conflict abandons the merge, prints the MERGE_CONFLICT + * marker on a line of its own, where the release dashboard looks for it, and fails. + * + * @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.authEnv The environment that hands git the token, for the push. + * @param {string} options.from The ref to merge, e.g. `origin/release/M26.test`. + * @param {string} options.into The branch the checkout is on, e.g. `main`. + * @param {string} options.message The merge commit message. + * @param {string} options.label How to name `from` in the log, e.g. `release/M26.test`. + * + * @throws {Error} When the merge conflicts or fails, or the push fails. + * + * @returns {Promise} True when a merge commit was made, false when there was nothing to merge. + */ +async function mergeAndPush({ core, exec, cwd, authEnv, from, into, message, label }) { + const identity = ['-c', `user.name=${BOT_IDENTITY.name}`, '-c', `user.email=${BOT_IDENTITY.email}`] + const result = await exec.getExecOutput('git', [...identity, 'merge', '--no-ff', '-m', message, from], { + cwd, + ignoreReturnCode: true, + }) + + if (isConflict(result)) { + // The abort leaves the checkout clean, so a later step can still use it. core.info prints a + // plain line: the marker has to open an output line to count, see the README. + await exec.getExecOutput('git', ['merge', '--abort'], { cwd, ignoreReturnCode: true }) + core.setOutput('conflict', into) + core.info( + `MERGE_CONFLICT: merging ${label} into ${into} conflicted. Resolve it on ${into} by hand and ` + + `push, then run this step again. Nothing was pushed to ${into}.` + ) + throw new Error( + `Merging ${label} into ${into} stopped on a conflict. Resolve it on ${into} by hand and push, ` + + `then run this step again: the merges already done are kept. Nothing was pushed to ${into}.` + ) + } + + if (result.exitCode !== 0) { + const output = [result.stdout, result.stderr].map((text) => text.trim()).filter(Boolean) + + throw new Error(`Merging ${label} into ${into} failed (exit code ${result.exitCode}):\n${output.join('\n')}`) + } + + const merged = !/Already up to date/.test(result.stdout) + if (!merged) { + core.info(`${into} already has ${label}: nothing to merge.`) + } + + await gitOrFail(exec, ['push', 'origin', `HEAD:refs/heads/${into}`], { cwd, env: authEnv }, `Pushing ${into}`) + + return merged +} + +/** + * Checks the inputs and the version, then merges the release branch into the base branch and the + * base branch back into develop, pushing each. The version has to be the one the release branch + * holds in every file its .puprc lists, so the tag matches the code. + * + * The base branch, not the release branch, is merged into develop: develop then descends from the + * base, and consecutive releases bring one version bump each instead of divergent copies of the same + * lines. Both merges are `--no-ff`, so each release is one merge commit in the history. A repository + * without a develop branch leaves `develop-branch` empty, and only the first merge is made. + * + * The checkout is the caller's, on any branch and at any depth: the three branches are fetched, with + * the full history when the checkout is shallow, and the checkout is left on the base branch at the + * merged commit, for the steps that build the release from it. + * + * @param {object} options + * @param {object} options.github The Octokit github-script supplies, for the tag and release reads. + * @param {object} options.core The @actions/core toolkit, for the log, the outputs and the summary. + * @param {object} options.exec The @actions/exec toolkit. + * @param {object} options.env The environment the action manifest put its inputs in. + * @param {string} options.cwd The checkout, the working directory by default. + * + * @throws {Error} When an input is wrong, the tag exists without a release, a merge conflicts or + * fails, or a fetch or push fails. + * + * @returns {Promise} Resolves once both branches are pushed and the outputs are set. + */ +async function runMerge({ github, core, exec, env, cwd = process.cwd() }) { + const branch = validateBranch(env.INPUT_BRANCH, 'branch') + const version = validateVersion(env.INPUT_VERSION) + const base = validateBranch(env.INPUT_BASE_BRANCH, 'base-branch') + const develop = validateBranch(env.INPUT_DEVELOP_BRANCH, 'develop-branch', { optional: true }) + const { owner, repo } = readRepository(env, 'Nothing was merged.') + + if (develop !== null && base === develop) { + throw new Error(`base-branch and develop-branch are both '${base}'; they have to be two branches. Nothing was merged.`) + } + if (branch === base || branch === develop) { + throw new Error(`branch '${branch}' is also the ${branch === base ? 'base' : 'develop'} branch. Nothing was merged.`) + } + + const branches = develop === null ? [branch, base] : [branch, base, develop] + for (const name of branches) { + const check = await exec.getExecOutput('git', ['check-ref-format', `refs/heads/${name}`], { + cwd, + ignoreReturnCode: true, + silent: true, + }) + if (check.exitCode !== 0) { + throw new Error(`'${name}' is not a valid branch name. Nothing was merged.`) + } + } + + const { tagged, releaseUrl } = await runPreflight({ github, owner, repo, version }) + + const token = String(env.INPUT_TOKEN || '') + const authEnv = { ...env, ...gitAuthEnv(token, env.GITHUB_SERVER_URL) } + if (token) { + core.setSecret(authorizationHeader(token)) + } + + // A shallow checkout, the actions/checkout default, has no history for the merges to find a base + // in; `--unshallow` fetches it along with the branches. + const shallow = (await exec.getExecOutput('git', ['rev-parse', '--is-shallow-repository'], { cwd, silent: true })).stdout.trim() + const refspecs = branches.map((name) => `+refs/heads/${name}:refs/remotes/origin/${name}`) + if (tagged) { + refspecs.push(`+refs/tags/${version}:refs/tags/${version}`) + } + await gitOrFail( + exec, + ['fetch', ...(shallow === 'true' ? ['--unshallow'] : []), 'origin', ...refspecs], + { cwd, env: authEnv }, + 'Fetching the branches' + ) + + await assertBranchHoldsVersion({ exec, cwd, branch, version }) + + // A tag with a published release is a retry only when that release came from this branch: its + // commit then contains the branch, and the merges below find nothing to do. A tag that does not + // contain the branch is an earlier release of the same version, which the branch was never bumped + // past; merging it would put a second set of changes under a version that is already out. + if (tagged) { + const ancestry = await exec.getExecOutput( + 'git', + ['merge-base', '--is-ancestor', `origin/${branch}`, `refs/tags/${version}^{commit}`], + { cwd, ignoreReturnCode: true, silent: true } + ) + if (ancestry.exitCode === 1) { + throw new Error( + `The tag ${version} already exists and does not contain ${branch}: ${version} was released ` + + `from another commit, and the branch was never bumped past it. Give the release a new ` + + 'version and prepare the branch again. Nothing was merged.' + ) + } + if (ancestry.exitCode !== 0) { + throw new Error(`Comparing ${branch} with the tag ${version} failed (exit code ${ancestry.exitCode}). Nothing was merged.`) + } + core.info(`${version} is already published (${releaseUrl}) from ${branch}: nothing to publish again.`) + } + + await gitOrFail(exec, ['checkout', '--quiet', '-B', base, `origin/${base}`], { cwd }, `Checking out ${base}`) + const mergedBase = await mergeAndPush({ + core, + exec, + cwd, + authEnv, + from: `origin/${branch}`, + into: base, + message: mergeMessage(branch, base), + label: branch, + }) + + // The commit the release is published at: the merge commit, or the base's tip on a retry that + // found the merge done. The caller publishes the release at it, so the tag is made at this commit + // and not at whatever the base branch points to by then; it is also the commit the caller builds + // from. + const baseSha = (await gitOrFail(exec, ['rev-parse', 'HEAD'], { cwd, silent: true }, `Reading the merged ${base} commit`)).trim() + + let mergedDevelop = null + if (develop === null) { + core.info(`develop-branch is empty: ${base} is not merged back into a develop branch.`) + } else { + await gitOrFail(exec, ['checkout', '--quiet', '-B', develop, `origin/${develop}`], { cwd }, `Checking out ${develop}`) + mergedDevelop = await mergeAndPush({ + core, + exec, + cwd, + authEnv, + from: base, + into: develop, + message: `Merge ${base} back into ${develop} (${branch})`, + label: base, + }) + await gitOrFail(exec, ['checkout', '--quiet', base], { cwd }, `Checking out ${base}`) + } + + core.setOutput('branch', branch) + core.setOutput('version', version) + core.setOutput('base-branch', base) + core.setOutput('base-sha', baseSha) + core.setOutput('develop-branch', develop ?? '') + core.setOutput('conflict', '') + + const did = (merged, from, into) => (merged ? `merged \`${from}\` into \`${into}\`` : `\`${into}\` already had \`${from}\``) + const developPart = develop === null ? 'no develop branch to merge back into' : did(mergedDevelop, base, develop) + await core.summary.addRaw(`Release ${version}: ${did(mergedBase, branch, base)}, ${developPart}.`, true).write() +} + +module.exports = { + runMerge, + runPreflight, + validateVersion, + validateBranch, + authorizationHeader, + gitAuthEnv, + mergeMessage, + isConflict, + BOT_IDENTITY, +} diff --git a/.github/actions/release-prep/release-prep.js b/.github/actions/release-prep/release-prep.js index 1729801..c7e3411 100644 --- a/.github/actions/release-prep/release-prep.js +++ b/.github/actions/release-prep/release-prep.js @@ -16,14 +16,10 @@ 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+)?$/ +// The three-or-four-parts pattern lives with the other version rules, shared with release-merge. +const { compareVersions, STELLARWP_VERSION_PATTERN } = require('../../lib/versions.js') +const { readRepository } = require('../../lib/github.js') +const { runOrFail } = require('../../lib/exec.js') /** * A semver version as semver.org defines it: MAJOR.MINOR.PATCH, an optional pre-release and @@ -623,31 +619,6 @@ function readCheckoutFile(cwd, name) { } } -/** - * Runs a command and fails with its output when it exits non-zero. - * - * @param {object} exec The @actions/exec toolkit. - * @param {string} command The program. - * @param {string[]} args Its arguments. - * @param {object} options Options for @actions/exec. - * @param {string} what What the command does, to open the error message. - * - * @throws {Error} When the command exits non-zero, holding what it printed. - * - * @returns {Promise} What the command printed to stdout. - */ -async function runOrFail(exec, command, args, options, what) { - const result = await exec.getExecOutput(command, args, { ...options, ignoreReturnCode: true }) - - if (result.exitCode !== 0) { - const output = [result.stdout, result.stderr].map((text) => text.trim()).filter(Boolean) - - throw new Error(`${what} failed (exit code ${result.exitCode}):\n${output.join('\n')}`) - } - - return result.stdout -} - /** * Validates the inputs, then bumps the version and replaces the TBDs with pup. * @@ -844,14 +815,7 @@ async function runCommit({ github, core, exec, env, cwd = process.cwd() }) { const branch = validateBranch(env.INPUT_BRANCH) 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.' - ) - } + const { owner, repo } = readRepository(env, 'Nothing was committed.') await runOrFail(exec, 'git', ['add', '--all'], { cwd }, 'Staging the changes') diff --git a/.github/lib/exec.js b/.github/lib/exec.js new file mode 100644 index 0000000..613d3c9 --- /dev/null +++ b/.github/lib/exec.js @@ -0,0 +1,33 @@ +'use strict' + +/** + * Running commands through @actions/exec, shared by the toolbox actions. Loaded by a path relative to + * each action's directory, like versions.js. + */ + +/** + * Runs a command and fails with its output when it exits non-zero. + * + * @param {object} exec The @actions/exec toolkit. + * @param {string} command The program. + * @param {string[]} args Its arguments. + * @param {object} options Options for @actions/exec. + * @param {string} what What the command does, to open the error message. + * + * @throws {Error} When the command exits non-zero, holding what it printed. + * + * @returns {Promise} What the command printed to stdout. + */ +async function runOrFail(exec, command, args, options, what) { + const result = await exec.getExecOutput(command, args, { ...options, ignoreReturnCode: true }) + + if (result.exitCode !== 0) { + const output = [result.stdout, result.stderr].map((text) => text.trim()).filter(Boolean) + + throw new Error(`${what} failed (exit code ${result.exitCode}):\n${output.join('\n')}`) + } + + return result.stdout +} + +module.exports = { runOrFail } diff --git a/.github/lib/github.js b/.github/lib/github.js new file mode 100644 index 0000000..09a03df --- /dev/null +++ b/.github/lib/github.js @@ -0,0 +1,68 @@ +'use strict' + +/** + * Reads of the GitHub API shared by the toolbox actions, through the Octokit actions/github-script + * supplies. Loaded by a path relative to each action's directory, like versions.js. + */ + +/** + * Reads `GITHUB_REPOSITORY` as owner and name. + * + * @param {object} env The environment. + * @param {string} what What was not done, to close the message, e.g. `Nothing was merged.` + * + * @throws {Error} When it is not set. + * + * @returns {{owner: string, repo: string}} The repository. + */ +function readRepository(env, what) { + 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 work on. ${what}`) + } + + return { owner, repo } +} + +/** + * Whether an Octokit error is the API saying a thing does not exist. + * + * @param {Error} error The error. + * + * @returns {boolean} True for a 404. + */ +function isNotFound(error) { + return error?.status === 404 +} + +/** + * Reads a tag of the repository. + * + * @param {object} options + * @param {object} options.github The Octokit github-script supplies. + * @param {string} options.owner The repository owner. + * @param {string} options.repo The repository name. + * @param {string} options.version The tag, a version. + * @param {string} options.what What was not done, to close the error message. + * + * @throws {Error} When the read fails for any reason but 404, so an API error is never taken for an + * absent tag. + * + * @returns {Promise} Whether the tag exists. + */ +async function tagExists({ github, owner, repo, version, what }) { + try { + await github.rest.git.getRef({ owner, repo, ref: `tags/${version}` }) + + return true + } catch (error) { + if (isNotFound(error)) { + return false + } + + throw new Error(`Reading tag ${version} failed (HTTP ${error.status}): ${error.message}. ${what}`) + } +} + +module.exports = { readRepository, isNotFound, tagExists } diff --git a/.github/lib/puprc.js b/.github/lib/puprc.js new file mode 100644 index 0000000..74eb6cd --- /dev/null +++ b/.github/lib/puprc.js @@ -0,0 +1,68 @@ +'use strict' + +/** + * Reading the version files a repository's .puprc declares, shared by the toolbox actions. Loaded by a + * path relative to each action's directory, like versions.js. + * + * pup bumps the version in every file listed under `paths.versions`, each with a regex whose first + * group is the text before the version and whose second group is the version itself. The same + * entries let an action read the version a branch holds without running pup. + */ + +/** + * Reads the version files .puprc declares. + * + * @param {string|null} text The contents of .puprc, or null when the branch has none. + * @param {string} what What was not done, to close the error message, e.g. `Nothing was merged.` + * + * @throws {Error} When there is no .puprc, it is not JSON, or it declares no version files. + * + * @returns {{file: string, regex: string}[]} The files and the regex that finds the version in each. + */ +function readVersionFiles(text, what) { + if (text === null) { + throw new Error( + 'No .puprc on the branch, so the version its files hold cannot be read. The version files come ' + + `from paths.versions in .puprc; see the release-prep action's README. ${what}` + ) + } + + let config + try { + config = JSON.parse(text) + } catch (error) { + throw new Error(`.puprc is not valid JSON (${error.message}). ${what}`) + } + + const versions = config?.paths?.versions + if (!Array.isArray(versions) || versions.length === 0) { + throw new Error(`.puprc has no paths.versions, so there is no version file to read. ${what}`) + } + + for (const entry of versions) { + if (typeof entry?.file !== 'string' || typeof entry?.regex !== 'string') { + throw new Error(`.puprc lists a paths.versions entry without a file and a regex. ${what}`) + } + } + + return versions.map(({ file, regex }) => ({ file, regex })) +} + +/** + * The version a file holds, found with the regex .puprc gives for it: the second group, the way pup + * reads and rewrites it. + * + * @param {string} content The file. + * @param {string} regex The regex from .puprc, two groups: the text before the version, the version. + * + * @throws {Error} When the regex is not one JavaScript accepts. + * + * @returns {string|null} The version, trimmed, or null when the regex matches nothing. + */ +function versionIn(content, regex) { + const match = new RegExp(regex).exec(content) + + return match && match[2] !== undefined ? match[2].trim() : null +} + +module.exports = { readVersionFiles, versionIn } diff --git a/.github/lib/versions.js b/.github/lib/versions.js index d9f840e..5780ee9 100644 --- a/.github/lib/versions.js +++ b/.github/lib/versions.js @@ -5,10 +5,18 @@ * * 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 + * repository checkout the runner already fetches for the action: there is nothing to publish * and nothing for a caller to install. */ +/** + * Three or four numeric parts, the shape the stellarwp changelog versioning accepts and the one the + * release-merge action publishes. 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+)?$/ + /** * Splits a version into its numbers, for comparing one part against another. * @@ -54,4 +62,39 @@ function compareVersions(a, b) { return 0 } -module.exports = { toParts, compareVersions } +/** + * Reads a version to release, refusing anything but three or four numeric parts. + * + * A pre-release is refused: the version becomes the tag and the title of a published release, and a + * beta or RC is not released this way. The release dashboard validates versions with this rule, and + * release-prep does under the stellarwp changelog versioning. + * + * @param {string} version The `version` input. + * @param {string} what What was not done, to close the message, e.g. `Nothing was merged.` + * + * @throws {Error} When it is empty or not of that shape. + * + * @returns {string} The version, without surrounding whitespace. + */ +function validateVersion(version, what) { + 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. ${what}` + ) + } + + if (!STELLARWP_VERSION_PATTERN.test(trimmed)) { + throw new Error( + `'${trimmed}' is not a version this action can release. It takes three or four numeric parts, ` + + 'such as 4.17.0 or 4.17.0.1: no pre-release suffix and no leading v, because the version ' + + `becomes the tag and the title of the published release. ${what}` + ) + } + + return trimmed +} + +module.exports = { toParts, compareVersions, validateVersion, STELLARWP_VERSION_PATTERN } diff --git a/README.md b/README.md index 3f7504d..551f89e 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,7 @@ Each phase ends with something people can actually use. - [ ] 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`. +- [x] Add `release-merge`: the release branch into the base branch, the base back into develop, each step safe to rerun and a conflict reported with a `MERGE_CONFLICT` line. - [ ] 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`. diff --git a/tests/actions/release-merge/release-merge.test.js b/tests/actions/release-merge/release-merge.test.js new file mode 100644 index 0000000..7193faf --- /dev/null +++ b/tests/actions/release-merge/release-merge.test.js @@ -0,0 +1,689 @@ +'use strict' + +const { describe, it } = require('node:test') +const assert = require('node:assert') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { execFileSync } = require('node:child_process') + +const { + runMerge, + runPreflight, + validateVersion, + validateBranch, + authorizationHeader, + gitAuthEnv, + mergeMessage, + isConflict, + BOT_IDENTITY, +} = require('../../../.github/actions/release-merge/release-merge.js') +const { coreWithOutputs, recordedRealExec, recordingExec, octokitFor } = require('../../support/actions.js') + +/** + * 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) { + return execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() +} + +/** + * Commits a file in a working copy. + * + * @param {string} dir The working copy. + * @param {string} name The file, relative to it. + * @param {string} contents What to write. + * @param {string} message The commit message. + */ +function commitFile(dir, name, contents, message) { + fs.writeFileSync(path.join(dir, name), contents) + git(dir, 'add', '-A') + git(dir, 'commit', '--quiet', '-m', message) +} + +/** + * A remote repository with the three branches the action merges, as a plugin repository has them + * after release prep: `main` at 4.16.0, `develop` ahead of it by one commit, and `release/M26.test` + * holding the bump to 4.17.0. + * + * The remote is a bare repository on disk, so the action's fetches and pushes are real git against a + * real remote, with no network. + * + * @param {object} options + * @param {boolean} options.baseConflicts Also move `main` on the line the release changes, so + * merging the release into it conflicts. + * @param {boolean} options.developConflicts Also move `develop` on that line, so merging `main` back + * into it conflicts. + * @param {string} options.releaseReadme The readme.txt of the release branch; its Stable tag is + * the second version file .puprc lists. + * @param {object} options.puprc The .puprc every branch carries. + * + * @returns {string} The bare repository. + */ +function remoteWith({ + baseConflicts = false, + developConflicts = false, + releaseReadme = 'Stable tag: 4.17.0\n', + puprc = PUPRC, +} = {}) { + const bare = fs.mkdtempSync(path.join(os.tmpdir(), 'release-merge-remote-')) + const seed = fs.mkdtempSync(path.join(os.tmpdir(), 'release-merge-seed-')) + + git(bare, 'init', '--quiet', '--bare', '-b', 'main') + + git(seed, 'init', '--quiet', '-b', 'main') + git(seed, 'config', 'user.name', 'Test') + git(seed, 'config', 'user.email', 'test@example.com') + fs.writeFileSync(path.join(seed, '.puprc'), JSON.stringify(puprc)) + fs.writeFileSync(path.join(seed, 'readme.txt'), 'Stable tag: 4.16.0\n') + commitFile(seed, 'plugin.php', 'Version: 4.16.0\n', 'Initial') + + git(seed, 'checkout', '--quiet', '-b', 'develop') + commitFile(seed, 'develop.txt', 'work in progress\n', 'Develop moves on') + if (developConflicts) { + commitFile(seed, 'plugin.php', 'Version: 4.16.1-dev\n', 'Develop bumps its own version') + } + + git(seed, 'checkout', '--quiet', '-b', 'release/M26.test', 'main') + fs.writeFileSync(path.join(seed, 'readme.txt'), releaseReadme) + commitFile(seed, 'plugin.php', 'Version: 4.17.0\n', 'Prepare release/M26.test (4.17.0)') + + if (baseConflicts) { + git(seed, 'checkout', '--quiet', 'main') + commitFile(seed, 'plugin.php', 'Version: 4.16.0.1\n', 'Hotfix on main') + } + + git(seed, 'remote', 'add', 'origin', bare) + git(seed, 'push', '--quiet', 'origin', 'main', 'develop', 'release/M26.test') + + return bare +} + +/** + * The checkout the caller hands the action: a clone of the remote on its base branch, as + * actions/checkout leaves it. + * + * @param {string} bare The remote. + * @param {object} options + * @param {boolean} options.shallow Clone with `--depth 1`, the actions/checkout default. + * + * @returns {string} The checkout. + */ +function checkoutOf(bare, { shallow = false } = {}) { + const work = fs.mkdtempSync(path.join(os.tmpdir(), 'release-merge-checkout-')) + // A file:// URL rather than a path: git ignores --depth on a local-path clone. + const args = ['clone', '--quiet', '--branch', 'main', ...(shallow ? ['--depth', '1'] : []), `file://${bare}`, work] + + execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) + + return work +} + +/** + * Tags a commit on the remote, as publishing a GitHub release does. + * + * @param {string} bare The remote. + * @param {string} version The tag. + * @param {string} ref The commit to tag, e.g. `main`. + */ +function tagRemote(bare, version, ref) { + git(bare, 'tag', version, ref) +} + +/** + * The contents of a file on a branch of the remote. + * + * @param {string} bare The remote. + * @param {string} branch The branch. + * @param {string} name The file. + * + * @returns {string} Its contents. + */ +function fileOn(bare, branch, name) { + return git(bare, 'show', `${branch}:${name}`) +} + +/** + * The environment the manifest hands the merge step, over the real one so that git is on the PATH. + * + * @param {object} overrides Inputs to change. + * + * @returns {object} The environment. + */ +function mergeEnv(overrides = {}) { + return { + ...process.env, + INPUT_BRANCH: 'release/M26.test', + INPUT_VERSION: '4.17.0', + INPUT_BASE_BRANCH: 'main', + INPUT_DEVELOP_BRANCH: 'develop', + INPUT_TOKEN: 'test-token', + GITHUB_REPOSITORY: 'stellarwp/plugin', + GITHUB_SERVER_URL: 'https://github.com', + ...overrides, + } +} + +/** The .puprc of the fixture: the plugin header and the readme's Stable tag hold the version. */ +const PUPRC = { + paths: { + versions: [ + { file: 'plugin.php', regex: '(Version: )(.+)' }, + { file: 'readme.txt', regex: '(Stable tag: )(.+)' }, + ], + }, +} + +/** The repository paths the action reads and writes, as a real Octokit spells them. */ +const API = { + tag: '/repos/stellarwp/plugin/git/ref/tags%2F4.17.0', + releaseByTag: '/repos/stellarwp/plugin/releases/tags/4.17.0', +} + +const RELEASE_URL = 'https://github.com/stellarwp/plugin/releases/tag/4.17.0' + +/** Rules under which the version has never been tagged, the common case. */ +const UNTAGGED = [] + +/** Rules under which the version is tagged and its release published, as a retry finds. */ +const PUBLISHED = [ + { method: 'GET', path: API.tag, body: { ref: 'refs/tags/4.17.0', object: { sha: 'abc' } } }, + { method: 'GET', path: API.releaseByTag, body: { html_url: RELEASE_URL } }, +] + +/** + * Each call as `command arg arg…`, for asserting the order the action worked in. + * + * @param {object[]} calls The calls an exec recorded. + * + * @returns {string[]} One line per call. + */ +function sequence(calls) { + return calls.map((call) => [call.command, ...call.args].join(' ')) +} + +/** + * Each request as `METHOD path`, for asserting what the action asked the API. + * + * @param {object[]} requests The requests recordingFetch recorded. + * + * @returns {string[]} One line per request. + */ +function requested(requests) { + return requests.map((request) => `${request.method} ${request.path}`) +} + +describe('release-merge', () => { + describe('validateVersion', () => { + it('accepts three or four numeric parts, the shape the dashboard publishes', () => { + assert.strictEqual(validateVersion('4.17.0'), '4.17.0') + assert.strictEqual(validateVersion('4.17.0.1'), '4.17.0.1') + }) + + it('reads the version without surrounding whitespace', () => { + assert.strictEqual(validateVersion(' 4.17.0\n'), '4.17.0') + }) + + it('refuses an empty version, saying the run was dispatched without one', () => { + assert.throws(() => validateVersion(''), /No version was given.*Nothing was merged/s) + assert.throws(() => validateVersion(undefined), /No version was given/) + }) + + it('refuses a pre-release, a leading v and fewer than three parts', () => { + for (const version of ['4.17.0-beta.1', 'v4.17.0', '4.17', '4.17.0.1.2', '4..0']) { + assert.throws(() => validateVersion(version), /three or four numeric parts.*Nothing was merged/s, version) + } + }) + }) + + describe('validateBranch', () => { + it('takes an empty value only for an optional input, as develop-branch is', () => { + assert.strictEqual(validateBranch('', 'develop-branch', { optional: true }), null) + assert.strictEqual(validateBranch(' ', 'develop-branch', { optional: true }), null) + assert.strictEqual(validateBranch('develop', 'develop-branch', { optional: true }), 'develop') + assert.throws(() => validateBranch('', 'base-branch'), /No base-branch was given/) + }) + + it('accepts a branch name', () => { + assert.strictEqual(validateBranch(' release/M26.test ', 'branch'), 'release/M26.test') + }) + + it('refuses an empty branch, naming the input', () => { + assert.throws(() => validateBranch('', 'develop-branch'), /develop-branch.*Nothing was merged/s) + }) + + it('refuses a full ref, naming the branch to pass instead', () => { + assert.throws(() => validateBranch('refs/heads/main', 'base-branch'), /pass the branch name.*'main'/s) + }) + }) + + describe('the git credentials', () => { + it('builds the header actions/checkout would have stored, for the token', () => { + const expected = Buffer.from('x-access-token:test-token').toString('base64') + + assert.strictEqual(authorizationHeader('test-token'), `AUTHORIZATION: basic ${expected}`) + }) + + it('hands the header to git through its environment, scoped to the server', () => { + const env = gitAuthEnv('test-token', 'https://github.com') + + assert.deepStrictEqual(env, { + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader', + GIT_CONFIG_VALUE_0: authorizationHeader('test-token'), + }) + }) + + it('scopes the header to a GitHub Enterprise server when the run is on one', () => { + assert.strictEqual( + gitAuthEnv('t', 'https://github.example.com/').GIT_CONFIG_KEY_0, + 'http.https://github.example.com/.extraheader' + ) + }) + }) + + describe('mergeMessage', () => { + it('names what was merged into what', () => { + assert.strictEqual(mergeMessage('release/M26.test', 'main'), 'Merge release/M26.test into main') + }) + }) + + describe('isConflict', () => { + it('recognises the lines git prints on a conflict', () => { + assert.strictEqual( + isConflict({ + exitCode: 1, + stdout: 'Auto-merging plugin.php\nCONFLICT (content): Merge conflict in plugin.php\nAutomatic merge failed; fix conflicts and then commit the result.\n', + stderr: '', + }), + true + ) + }) + + it('does not read any other merge failure as a conflict', () => { + assert.strictEqual(isConflict({ exitCode: 128, stdout: '', stderr: 'fatal: not something we can merge\n' }), false) + assert.strictEqual(isConflict({ exitCode: 0, stdout: 'Already up to date.\n', stderr: '' }), false) + }) + }) + + describe('runPreflight', () => { + it('finds nothing when the version was never tagged', async () => { + const { github, requests } = await octokitFor(UNTAGGED) + + const result = await runPreflight({ github, owner: 'stellarwp', repo: 'plugin', version: '4.17.0' }) + + assert.deepStrictEqual(result, { tagged: false, releaseUrl: null }) + assert.deepStrictEqual(requested(requests), [`GET ${API.tag}`]) + }) + + it('finds the published release when the tag exists, so a retry can go on', async () => { + const { github, requests } = await octokitFor(PUBLISHED) + + const result = await runPreflight({ github, owner: 'stellarwp', repo: 'plugin', version: '4.17.0' }) + + assert.deepStrictEqual(result, { tagged: true, releaseUrl: RELEASE_URL }) + assert.deepStrictEqual(requested(requests), [`GET ${API.tag}`, `GET ${API.releaseByTag}`]) + }) + + it('refuses a tag that exists without a published release, before anything is merged', async () => { + const { github } = await octokitFor([PUBLISHED[0]]) + + await assert.rejects( + runPreflight({ github, owner: 'stellarwp', repo: 'plugin', version: '4.17.0' }), + /tag 4\.17\.0 already exists.*no published release.*Nothing was merged/s + ) + }) + + it('fails on an API error rather than reading it as an absent tag', async () => { + const { github } = await octokitFor([{ method: 'GET', path: API.tag, status: 500, body: { message: 'boom' } }]) + + await assert.rejects( + runPreflight({ github, owner: 'stellarwp', repo: 'plugin', version: '4.17.0' }), + /Reading tag 4\.17\.0 failed.*500/s + ) + }) + }) + + describe('runMerge', () => { + it('merges the release into the base, the base back into develop, and pushes both', async (t) => { + const bare = remoteWith() + const cwd = checkoutOf(bare) + const { github } = await octokitFor(UNTAGGED) + const { core, outputs, summary } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + + await runMerge({ github, core, exec, env: mergeEnv(), cwd }) + + assert.strictEqual(fileOn(bare, 'main', 'plugin.php'), 'Version: 4.17.0') + assert.strictEqual(fileOn(bare, 'develop', 'plugin.php'), 'Version: 4.17.0') + assert.strictEqual(fileOn(bare, 'develop', 'develop.txt'), 'work in progress') + assert.strictEqual(git(bare, 'log', '-1', '--format=%s', 'main'), 'Merge release/M26.test into main') + assert.strictEqual(git(bare, 'log', '-1', '--format=%s', 'develop'), 'Merge main back into develop (release/M26.test)') + assert.strictEqual(git(bare, 'log', '-1', '--format=%an <%ae>', 'main'), `${BOT_IDENTITY.name} <${BOT_IDENTITY.email}>`) + // A merge commit, never a fast-forward, so the release shows as one in the history. + assert.strictEqual(git(bare, 'rev-list', '--merges', '--count', 'main'), '1') + + assert.deepStrictEqual(outputs(), { + branch: 'release/M26.test', + version: '4.17.0', + 'base-branch': 'main', + // The merge commit on the remote: what the caller builds from and tags. + 'base-sha': git(bare, 'rev-parse', 'main'), + 'develop-branch': 'develop', + conflict: '', + }) + assert.match(summary(), /release\/M26\.test.*main.*develop/s) + }) + + it('merges only into the base when develop-branch is empty, for a repository without develop', async (t) => { + const bare = remoteWith() + const cwd = checkoutOf(bare) + const develop = git(bare, 'rev-parse', 'develop') + const { github } = await octokitFor(UNTAGGED) + const { core, outputs, summary, logged } = await coreWithOutputs(t) + const { exec, calls } = await recordedRealExec() + + await runMerge({ github, core, exec, env: mergeEnv({ INPUT_DEVELOP_BRANCH: '' }), cwd }) + + assert.strictEqual(fileOn(bare, 'main', 'plugin.php'), 'Version: 4.17.0') + // develop was neither fetched nor touched. + assert.strictEqual(git(bare, 'rev-parse', 'develop'), develop) + assert.ok(!sequence(calls).some((line) => line.includes('refs/heads/develop')), sequence(calls).join('\n')) + assert.strictEqual(calls.filter((call) => call.args[0] === 'push').length, 1) + assert.strictEqual(git(cwd, 'symbolic-ref', '--short', 'HEAD'), 'main') + assert.strictEqual(outputs()['develop-branch'], '') + assert.strictEqual(outputs().conflict, '') + assert.match(logged(), /develop-branch is empty/) + assert.match(summary(), /no develop branch/) + }) + + it('leaves the checkout on the merged base, for the steps that build from it', async (t) => { + const bare = remoteWith() + const cwd = checkoutOf(bare) + const { github } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + + await runMerge({ github, core, exec, env: mergeEnv(), cwd }) + + assert.strictEqual(git(cwd, 'symbolic-ref', '--short', 'HEAD'), 'main') + assert.strictEqual(git(cwd, 'rev-parse', 'HEAD'), git(bare, 'rev-parse', 'main')) + assert.strictEqual(git(cwd, 'status', '--porcelain'), '') + }) + + it('hands the token to git only for the fetch and the pushes, through the environment', async (t) => { + const bare = remoteWith() + const cwd = checkoutOf(bare) + const { github } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec, calls } = await recordedRealExec() + + await runMerge({ github, core, exec, env: mergeEnv(), cwd }) + + const remote = calls.filter((call) => /^(fetch|push)$/.test(call.args[0])) + const local = calls.filter((call) => !/^(fetch|push)$/.test(call.args[0])) + + assert.strictEqual(remote.length, 3) + for (const call of remote) { + assert.strictEqual(call.options.env.GIT_CONFIG_VALUE_0, authorizationHeader('test-token')) + } + for (const call of local) { + assert.strictEqual(call.options.env?.GIT_CONFIG_COUNT, undefined, sequence([call])[0]) + } + // Nothing was written to the checkout's config either: git exits 1 on a key that is not set. + assert.throws(() => git(cwd, 'config', '--local', '--get-all', 'http.https://github.com/.extraheader')) + }) + + it('does nothing on a retry after a late failure: the merges are no-ops and the pushes are empty', async (t) => { + const bare = remoteWith() + const first = checkoutOf(bare) + const { github } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + + await runMerge({ github, core, exec, env: mergeEnv(), cwd: first }) + const main = git(bare, 'rev-parse', 'main') + const develop = git(bare, 'rev-parse', 'develop') + // The publish step then tagged the merge, so the retry finds the tag with its release. + tagRemote(bare, '4.17.0', 'main') + const published = await octokitFor(PUBLISHED) + + const second = checkoutOf(bare) + const retry = await coreWithOutputs(t) + await runMerge({ github: published.github, core: retry.core, exec, env: mergeEnv(), cwd: second }) + + assert.strictEqual(git(bare, 'rev-parse', 'main'), main) + assert.strictEqual(git(bare, 'rev-parse', 'develop'), develop) + assert.match(retry.logged(), /main already has release\/M26\.test/) + assert.strictEqual(retry.outputs().conflict, '') + // Nothing was merged, so the commit to publish at is the base's tip, the one holding the release. + assert.strictEqual(retry.outputs()['base-sha'], main) + }) + + it('says so when the release is already published from this branch, so the publish has nothing to do', async (t) => { + const bare = remoteWith() + // A run that merged and published, then failed in a later step of the caller. + tagRemote(bare, '4.17.0', 'release/M26.test') + const cwd = checkoutOf(bare) + const { github } = await octokitFor(PUBLISHED) + const { core, logged } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + + await runMerge({ github, core, exec, env: mergeEnv(), cwd }) + + assert.match(logged(), /4\.17\.0 is already published.*nothing to publish again/s) + assert.strictEqual(fileOn(bare, 'develop', 'plugin.php'), 'Version: 4.17.0') + }) + + it('refuses a published version whose tag does not contain the branch: the branch was not bumped', async (t) => { + const bare = remoteWith() + // 4.17.0 was released from main before this branch was cut, and the branch still says 4.17.0. + tagRemote(bare, '4.17.0', 'main') + const cwd = checkoutOf(bare) + const main = git(bare, 'rev-parse', 'main') + const develop = git(bare, 'rev-parse', 'develop') + const { github } = await octokitFor(PUBLISHED) + const { core } = await coreWithOutputs(t) + const { exec, calls } = await recordedRealExec() + + await assert.rejects( + runMerge({ github, core, exec, env: mergeEnv(), cwd }), + /tag 4\.17\.0 already exists and does not contain release\/M26\.test.*new version.*Nothing was merged/s + ) + + assert.strictEqual(git(bare, 'rev-parse', 'main'), main) + assert.strictEqual(git(bare, 'rev-parse', 'develop'), develop) + // `git -c user.name=… merge --no-ff` is the merge; merge-base is only the ancestry check. + assert.ok(!sequence(calls).some((line) => / merge --no-ff |^git push/.test(line)), sequence(calls).join('\n')) + }) + + it('prints the MERGE_CONFLICT marker and pushes nothing when the release conflicts with the base', async (t) => { + const bare = remoteWith({ baseConflicts: true }) + const cwd = checkoutOf(bare) + const main = git(bare, 'rev-parse', 'main') + const develop = git(bare, 'rev-parse', 'develop') + const { github } = await octokitFor(UNTAGGED) + const { core, outputs, logged } = await coreWithOutputs(t) + const { exec, calls } = await recordedRealExec() + + await assert.rejects( + runMerge({ github, core, exec, env: mergeEnv(), cwd }), + /release\/M26\.test into main stopped on a conflict.*Nothing was pushed to main/s + ) + + // The marker opens a line of its own: that is what the dashboard's detector anchors on. + assert.match(logged(), /^MERGE_CONFLICT: .*release\/M26\.test.*main/m) + assert.strictEqual(outputs().conflict, 'main') + assert.strictEqual(git(bare, 'rev-parse', 'main'), main) + assert.strictEqual(git(bare, 'rev-parse', 'develop'), develop) + assert.ok(!sequence(calls).some((line) => line.startsWith('git push')), 'nothing was pushed') + // The conflicted merge was abandoned, so the checkout is usable afterwards. + assert.strictEqual(git(cwd, 'status', '--porcelain'), '') + assert.throws(() => git(cwd, 'rev-parse', '--verify', '--quiet', 'MERGE_HEAD')) + }) + + it('prints the marker and leaves develop untouched when the base conflicts with develop', async (t) => { + const bare = remoteWith({ developConflicts: true }) + const cwd = checkoutOf(bare) + const develop = git(bare, 'rev-parse', 'develop') + const { github } = await octokitFor(UNTAGGED) + const { core, outputs, logged } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + + await assert.rejects( + runMerge({ github, core, exec, env: mergeEnv(), cwd }), + /main into develop stopped on a conflict.*Nothing was pushed to develop/s + ) + + assert.match(logged(), /^MERGE_CONFLICT: .*main.*develop/m) + assert.strictEqual(outputs().conflict, 'develop') + // The release did reach the base: a retry finds that merge done and only redoes develop. + assert.strictEqual(fileOn(bare, 'main', 'plugin.php'), 'Version: 4.17.0') + assert.strictEqual(git(bare, 'rev-parse', 'develop'), develop) + assert.strictEqual(git(cwd, 'status', '--porcelain'), '') + }) + + it('refuses a version the branch does not hold, naming the file, before merging anything', async (t) => { + const bare = remoteWith() + const cwd = checkoutOf(bare) + const main = git(bare, 'rev-parse', 'main') + const { github } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec, calls } = await recordedRealExec() + + await assert.rejects( + runMerge({ github, core, exec, env: mergeEnv({ INPUT_VERSION: '4.18.0' }), cwd }), + /release\/M26\.test holds 4\.17\.0 in plugin\.php, not 4\.18\.0.*prepared with.*Nothing was merged/s + ) + + assert.strictEqual(git(bare, 'rev-parse', 'main'), main) + assert.ok(!sequence(calls).some((line) => / merge --no-ff |^git push/.test(line)), sequence(calls).join('\n')) + }) + + it('checks every version file .puprc lists, so a half-prepared branch is refused too', async (t) => { + const bare = remoteWith({ releaseReadme: 'Stable tag: 4.16.0\n' }) + const cwd = checkoutOf(bare) + const { github } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + + await assert.rejects( + runMerge({ github, core, exec, env: mergeEnv(), cwd }), + /release\/M26\.test holds 4\.16\.0 in readme\.txt, not 4\.17\.0/ + ) + }) + + it('refuses a branch whose version file does not match the .puprc regex', async (t) => { + const bare = remoteWith({ releaseReadme: 'No stable tag here\n' }) + const cwd = checkoutOf(bare) + const { github } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + + await assert.rejects( + runMerge({ github, core, exec, env: mergeEnv(), cwd }), + /readme\.txt on release\/M26\.test holds no version.*\(Stable tag: \)\(\.\+\)/s + ) + }) + + it('names the file when the .puprc regex is not one JavaScript accepts', async (t) => { + const bare = remoteWith({ puprc: { paths: { versions: [{ file: 'plugin.php', regex: '(Version: )(?<' }] } } }) + const cwd = checkoutOf(bare) + const { github } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec, calls } = await recordedRealExec() + + await assert.rejects( + runMerge({ github, core, exec, env: mergeEnv(), cwd }), + /regex \.puprc gives for plugin\.php \(\(Version: \)\(\?<\) is not one JavaScript accepts.*Nothing was merged/s + ) + assert.ok(!sequence(calls).some((line) => / merge --no-ff |^git push/.test(line))) + }) + + it('refuses a branch without a .puprc, since the version it holds cannot be read', async (t) => { + const bare = remoteWith() + // Remove .puprc from the release branch only. + const work = checkoutOf(bare) + git(work, 'checkout', '--quiet', 'release/M26.test') + git(work, 'config', 'user.name', 'Test') + git(work, 'config', 'user.email', 'test@example.com') + git(work, 'rm', '--quiet', '.puprc') + git(work, 'commit', '--quiet', '-m', 'Drop .puprc') + git(work, 'push', '--quiet', 'origin', 'release/M26.test') + const cwd = checkoutOf(bare) + const { github } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec } = await recordedRealExec() + + await assert.rejects(runMerge({ github, core, exec, env: mergeEnv(), cwd }), /No \.puprc on the branch.*Nothing was merged/s) + }) + + it('fetches the full history first when the caller made a shallow checkout', async (t) => { + const bare = remoteWith() + const cwd = checkoutOf(bare, { shallow: true }) + assert.strictEqual(git(cwd, 'rev-parse', '--is-shallow-repository'), 'true') + const { github } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec, calls } = await recordedRealExec() + + await runMerge({ github, core, exec, env: mergeEnv(), cwd }) + + assert.ok(sequence(calls).some((line) => line.startsWith('git fetch --unshallow')), sequence(calls).join('\n')) + assert.strictEqual(fileOn(bare, 'develop', 'plugin.php'), 'Version: 4.17.0') + }) + + it('refuses a tag without a release before touching git', async (t) => { + const bare = remoteWith() + const cwd = checkoutOf(bare) + const { github } = await octokitFor([PUBLISHED[0]]) + const { core } = await coreWithOutputs(t) + const { exec, calls } = await recordedRealExec() + + await assert.rejects(runMerge({ github, core, exec, env: mergeEnv(), cwd }), /no published release/) + + assert.ok(!sequence(calls).some((line) => /^git (fetch|push|checkout)| merge --no-ff /.test(line)), sequence(calls).join('\n')) + }) + + it('refuses a branch name git would refuse, before asking the API anything', async (t) => { + const { github, requests } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec } = recordingExec([{ command: 'git', args: /^check-ref-format .*release\/bad\.\.name/, exitCode: 1 }, { command: 'git' }]) + + await assert.rejects( + runMerge({ github, core, exec, env: mergeEnv({ INPUT_BRANCH: 'release/bad..name' }), cwd: os.tmpdir() }), + /'release\/bad\.\.name' is not a valid branch name/ + ) + + assert.deepStrictEqual(requests, []) + }) + + it('refuses the same branch twice, which would merge a branch into itself', async (t) => { + const { github } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec, calls } = recordingExec([{ command: 'git' }]) + + await assert.rejects( + runMerge({ github, core, exec, env: mergeEnv({ INPUT_DEVELOP_BRANCH: 'main' }), cwd: os.tmpdir() }), + /base-branch and develop-branch are both 'main'/ + ) + assert.deepStrictEqual(calls, []) + }) + + it('fails when GITHUB_REPOSITORY is missing, since it does not know whose tags to read', async (t) => { + const { github } = await octokitFor(UNTAGGED) + const { core } = await coreWithOutputs(t) + const { exec } = recordingExec([{ command: 'git' }]) + + await assert.rejects( + runMerge({ github, core, exec, env: mergeEnv({ GITHUB_REPOSITORY: '' }), cwd: os.tmpdir() }), + /GITHUB_REPOSITORY is not set/ + ) + }) + }) + +}) diff --git a/tests/actions/release-prep/release-prep.test.js b/tests/actions/release-prep/release-prep.test.js index 378bfdc..319da0e 100644 --- a/tests/actions/release-prep/release-prep.test.js +++ b/tests/actions/release-prep/release-prep.test.js @@ -25,7 +25,7 @@ const { compareVersions, readCurrentVersion, } = require('../../../.github/actions/release-prep/release-prep.js') -const { coreWithOutputs, recordingExec, octokitFor } = require('../../support/actions.js') +const { coreWithOutputs, recordingExec, recordedRealExec, octokitFor } = require('../../support/actions.js') /** A .puprc that bumps the plugin header and replaces TBDs under src/. */ const PUPRC = { @@ -191,37 +191,6 @@ 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. * diff --git a/tests/support/actions.js b/tests/support/actions.js index 35a02a4..0c9f97c 100644 --- a/tests/support/actions.js +++ b/tests/support/actions.js @@ -308,6 +308,38 @@ function recordingExec(rules = []) { return { exec, calls } } +/** + * The real @actions/exec, wrapped to record each call and what it printed. + * + * For an action whose logic is in the commands themselves, such as git merges, a stand-in would only + * test the test. 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('') } +} + /** * Both arguments actions/github-script passes to a script, ready for one test. * @@ -331,6 +363,7 @@ module.exports = { coreWithOutputs, recordingFetch, recordingExec, + recordedRealExec, readOutputs, isLogWrite, }