diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md new file mode 100644 index 0000000..ac7532b --- /dev/null +++ b/.github/actions/release-merge/README.md @@ -0,0 +1,203 @@ +# release-merge + +Merges a release: the release branch into the base branch, then the base branch back into develop, +through the GitHub API. **The base branch is `main`, or `master` where that is the repository's name +for it**, set with `base-branch`: + +```yaml +- uses: stellarwp/plugin-toolbox/.github/actions/release-merge@v1 + id: merge + with: + branch: ${{ inputs.branch }} + version: ${{ inputs.version }} + +# The merged commit, to build the release from: +- uses: actions/checkout@v6 + with: + ref: ${{ steps.merge.outputs.base-sha }} + +# 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 a +checkout of `base-sha`. + +## 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. Checks that the release branch, the base branch and develop exist, then 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`). +4. Merges the **merged commit** into develop as a merge commit, `Merge main back into develop + (release/…)`. 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 merge commit on the base branch is the `base-sha` output: what the caller checks out, builds +from and tags. GitHub makes both merge commits under the identity of `token`, `github-actions[bot]` +for the default token, and signs them. The API never fast-forwards, so each release is one merge +commit in the history. + +The action reads and writes no checkout: it needs none before it, and leaves whatever checkout the +job has as it was. Check out `base-sha` after it for the steps that build the release. + +## 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 nothing to merge: GitHub answers that the base + already contains the head, and nothing is written. +- 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 + +GitHub refuses the merge, nothing is written for that branch, and the step fails with the branch +named. The branches already merged stay merged: 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 +API does not say which files conflict; the merge done by hand shows them. + +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 merged into 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. + +A merge GitHub refuses for another reason (the base branch moved while the merge was being made) +fails the step without the marker; running the step again is enough. + +## 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 exist in the repository, the base and develop have to be +two different branches (when there is a develop), and the release branch cannot be either of them. + +The release branch's tip is read once, and that commit is what the version files are read at, what +a published tag is compared against, and what is merged into the base. A commit pushed to the branch +after the check is not merged by that run; run the step again for it. The branch name appears in +the merge message and in the messages, not in what the API is asked. + +The tip is read when the step runs, which in a deploy that waits for an approval is later than the +run started. Pass `head`, the commit the run was started on (`github.sha` in a `workflow_dispatch` +run), and a branch whose tip is another commit is refused before anything is merged: the commit +that was built and reviewed is no longer the tip, so the deploy is started again. The template does +this; without `head`, the tip at that moment is what merges. + +## Inputs + +| Input | Required | Default | What it is | +|---|---|---|---| +| `branch` | yes | | The release branch to merge, e.g. `release/M26.jigglypuff`. A branch name. | +| `head` | no | | The commit the release branch has to be at, as a full hash: `github.sha`, the commit the run was started on. Empty checks nothing. See [Versions it refuses](#versions-it-refuses). | +| `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 merges and the 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 check out, build from and publish the release at. | +| `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 two merges and for the branch, tag, +release and file reads. It only ever reaches github-script's client: it is never handed to git, +never on a command line and never in the log. The caller's checkout, when there is one, can use +`persist-credentials: false`. + +The default `github.token` is enough when the repository's rules allow what the action does: a +merge written straight to the base branch and to develop. The merges are made through the API, not +opened as pull requests, so a base branch that only accepts pull requests refuses the merge, with +any token that is not on the rule's bypass list; a rule that requires signed commits is satisfied, +since GitHub signs the merge commits it makes. GiveWP's rules are the reference shape: the branches +are protected from deletion and non-fast-forward pushes, and `github.token` writes 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. + +## 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, all through github-script's +Octokit. 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 tests, `node --test` from the repository root, run the action against a recorded client and +assert the requests it sends and the ones it does not; no git and no network. diff --git a/.github/actions/release-merge/action.yml b/.github/actions/release-merge/action.yml new file mode 100644 index 0000000..c947758 --- /dev/null +++ b/.github/actions/release-merge/action.yml @@ -0,0 +1,99 @@ +# 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. Everything goes through the GitHub API: the branch, +# tag and release reads, the version-file reads and the merges themselves. No checkout is needed; +# the caller checks out `base-sha` afterwards to build from it. +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. + required: true + head: + description: >- + The commit the release branch has to be at, as a full hash; empty checks nothing. A deploy that + waits for an approval merges the branch's tip when the approval comes. Pass the commit the run + was started on (`github.sha`), and a branch that moved in between is refused instead of merged. + required: false + default: '' + 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 merges are made with, and the branch, tag, release and file reads. It needs + `contents: write` on the repository, so the job needs that permission too. GitHub makes the + merge commits under its identity and signs them. It only ever reaches github-script's client, + never git or the log. + 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 check out, build from and publish the + release at. + 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_HEAD: ${{ inputs.head }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_BASE_BRANCH: ${{ inputs.base-branch }} + INPUT_DEVELOP_BRANCH: ${{ inputs.develop-branch }} + with: + # Every read and the merges 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, 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..1e77dc0 --- /dev/null +++ b/.github/actions/release-merge/release-merge.js @@ -0,0 +1,502 @@ +'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 asks GitHub for + * the two merges. Everything goes through the Octokit github-script supplies: the action reads and + * writes no checkout, so a test runs it against a recorded client and asserts 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, readTag } = require('../../lib/github.js') +const { readVersionFiles, versionIn } = require('../../lib/puprc.js') + +/** + * 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 the repository has the branch is checked against the + * API, 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 +} + +/** A full commit hash, the only form `head` takes: a branch name would be read at run time again. */ +const COMMIT_PATTERN = /^[0-9a-f]{40}$/ + +/** + * Reads the `head` input: the commit the release branch has to be at, or nothing. + * + * A deploy that waits for an approval merges the branch's tip when the approval comes, which may + * not be the commit the run was started on. The caller passes that commit (`github.sha` in a + * workflow_dispatch run) and a branch that moved in between is refused instead of merged. + * + * @param {string} head The input value. + * + * @throws {Error} When it is neither empty nor a full commit hash. + * + * @returns {string|null} The hash, lower-cased, or null for an empty value. + */ +function readHead(head) { + const trimmed = String(head ?? '') + .trim() + .toLowerCase() + + if (trimmed === '') { + return null + } + + if (!COMMIT_PATTERN.test(trimmed)) { + throw new Error( + `head '${trimmed}' is not a full commit hash. Pass the commit the release branch has to be at, ` + + 'such as github.sha, or leave it empty. Nothing was merged.' + ) + } + + return trimmed +} + +/** + * 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 merge request is GitHub reporting a conflict, as opposed to any other refusal. + * + * The merge endpoint answers 409 for two things: a conflict between the branches, and a base that + * moved while the merge was being made. Only the first is a conflict for the caller to resolve. + * + * @param {Error} error The Octokit error. + * + * @returns {boolean} True for a conflict. + */ +function isConflict(error) { + return error?.status === 409 && /conflict/i.test(String(error.message)) +} + +/** + * 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 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, tagSha: string|null, releaseUrl: string|null}>} Whether the + * tag exists, the commit it points at, and the published release's URL when it does. + */ +async function runPreflight({ github, owner, repo, version }) { + const tagSha = await readTag({ github, owner, repo, version, what: 'Nothing was merged.' }) + if (tagSha === null) { + return { tagged: false, tagSha: null, releaseUrl: null } + } + + try { + const { data: release } = await github.rest.repos.getReleaseByTag({ owner, repo, tag: version }) + + return { tagged: true, tagSha, 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.` + ) + } +} + +/** + * Checks that the repository has a branch, and reads its tip. + * + * Read before any file is read from the branch: a 404 on a file read cannot tell a missing file + * from a missing branch. + * + * @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.branch The branch name. + * + * @throws {Error} When the repository has no such branch, or the read fails. + * + * @returns {Promise} The commit the branch points at. + */ +async function readBranchTip({ github, owner, repo, branch }) { + try { + return (await github.rest.repos.getBranch({ owner, repo, branch })).data.commit.sha + } catch (error) { + if (isNotFound(error)) { + throw new Error(`'${branch}' is not a branch of ${owner}/${repo}. Nothing was merged.`) + } + + throw new Error(`Reading branch ${branch} failed (HTTP ${error.status}): ${error.message}. Nothing was merged.`) + } +} + +/** + * Reads a file from a branch 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.branch The branch, known to exist, for the message. + * @param {string} options.ref The commit to read at: the branch's tip as it was read, so every + * read sees the same commit whatever is pushed to the branch meanwhile. + * @param {string} options.file The path in the repository. + * + * @throws {Error} When the read fails for any reason but 404. + * + * @returns {Promise} The contents, or null when the branch has no such file (or the + * path is a directory). + */ +async function fileAt({ github, owner, repo, branch, ref, file }) { + let data + try { + data = (await github.rest.repos.getContent({ owner, repo, path: file, ref })).data + } catch (error) { + if (isNotFound(error)) { + return null + } + + throw new Error(`Reading ${file} on ${branch} failed (HTTP ${error.status}): ${error.message}. Nothing was merged.`) + } + + if (Array.isArray(data) || data.type !== 'file') { + return null + } + + return Buffer.from(data.content, 'base64').toString('utf8') +} + +/** + * 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.github The Octokit github-script supplies. + * @param {string} options.owner The repository owner. + * @param {string} options.repo The repository name. + * @param {string} options.branch The release branch, for the messages. + * @param {string} options.ref The commit to read the files at: the branch's tip as it was read. + * @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({ github, owner, repo, branch, ref, version }) { + const read = (file) => fileAt({ github, owner, repo, branch, ref, file }) + const files = readVersionFiles(await read('.puprc'), 'Nothing was merged.') + + for (const { file, regex } of files) { + const content = await read(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.' + ) + } + } +} + +/** + * Whether a commit contains a branch: the tag of a published version, against the release branch. + * + * @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.branch The branch, for the message. + * @param {string} options.ref The branch's tip as it was read, a hash: what the commit has to + * contain, so a push to the branch meanwhile changes nothing here. + * @param {string} options.sha The commit; a hash rather than the tag's name, which a branch of + * the same name would shadow. + * + * @throws {Error} When the comparison fails. + * + * @returns {Promise} True when the commit is the branch's tip or descends from it. + */ +async function commitContainsBranch({ github, owner, repo, branch, ref, sha }) { + try { + const { data } = await github.rest.repos.compareCommitsWithBasehead({ owner, repo, basehead: `${ref}...${sha}` }) + + return data.status === 'ahead' || data.status === 'identical' + } catch (error) { + throw new Error(`Comparing ${branch} with ${sha} failed (HTTP ${error.status}): ${error.message}. Nothing was merged.`) + } +} + +/** + * Merges one branch, or commit, into another as a merge commit, through the API. + * + * GitHub makes the merge commit under the token's identity and signs it. A base that already holds + * the head answers 204 and nothing is written, which is what a retry after a late failure finds. A + * conflict prints the MERGE_CONFLICT marker on a line of its own, where the release dashboard looks + * for it, and fails; nothing is written for that branch. + * + * @param {object} options + * @param {object} options.github The Octokit github-script supplies. + * @param {object} options.core The @actions/core toolkit. + * @param {string} options.owner The repository owner. + * @param {string} options.repo The repository name. + * @param {string} options.into The branch merged into, e.g. `main`. + * @param {string} options.head What is merged: a branch name or a commit hash. + * @param {string} options.message The merge commit message. + * @param {string} options.label How to name `head` in the log, e.g. `release/M26.test`. + * + * @throws {Error} When the merge conflicts or GitHub refuses it. + * + * @returns {Promise<{merged: boolean, sha: string}>} Whether a merge commit was made, and the commit + * `into` points at afterwards: the merge commit, or its tip when there was nothing to merge. + */ +async function mergeInto({ github, core, owner, repo, into, head, message, label }) { + let response + try { + response = await github.rest.repos.merge({ owner, repo, base: into, head, commit_message: message }) + } catch (error) { + if (isConflict(error)) { + // core.info prints a plain line: the marker has to open an output line to count, see the README. + 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 merged into ${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 merged into ${into}.` + ) + } + + if (error?.status === 409) { + throw new Error( + `Merging ${label} into ${into} was refused: ${into} moved while the merge was being made ` + + `(${error.message}). Run this step again; the merges already done are kept.` + ) + } + + throw new Error(`Merging ${label} into ${into} failed (HTTP ${error?.status}): ${error?.message}`) + } + + if (response.status === 204) { + core.info(`${into} already has ${label}: nothing to merge.`) + + return { merged: false, sha: await readBranchTip({ github, owner, repo, branch: into }) } + } + + return { merged: true, sha: response.data.sha } +} + +/** + * Checks the inputs and the version, then merges the release branch into the base branch and the + * base branch back into develop. 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. The API always makes a merge commit, never a fast-forward, 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. + * + * Nothing is read from or written to a checkout: the caller checks out `base-sha` afterwards for + * the steps that build the release from it. + * + * @param {object} options + * @param {object} options.github The Octokit github-script supplies, built from the token. + * @param {object} options.core The @actions/core toolkit, for the log, the outputs and the summary. + * @param {object} options.env The environment the action manifest put its inputs in. + * + * @throws {Error} When an input is wrong, a branch is missing, the tag exists without a release, a + * merge conflicts, or a read or a merge fails. + * + * @returns {Promise} Resolves once both merges are made and the outputs are set. + */ +async function runMerge({ github, core, env }) { + 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 head = readHead(env.INPUT_HEAD) + 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 { tagged, tagSha, releaseUrl } = await runPreflight({ github, owner, repo, version }) + + // The release branch's tip is read once and that commit is what the version files are read at, + // what the tag is compared against and what is merged. Read by name at each step, a commit pushed + // to the branch between the version check and the merge would reach the base unchecked; a tag + // named like the branch could also change what a read by name returns. + const branchSha = await readBranchTip({ github, owner, repo, branch }) + if (head !== null && head !== branchSha) { + throw new Error( + `${branch} is at ${branchSha}, not at ${head}, the commit this run was started on: the branch ` + + 'moved after the run started, so its tip is not the commit that was built and reviewed. Start ' + + 'the deploy again on the branch as it is now. Nothing was merged.' + ) + } + for (const name of develop === null ? [base] : [base, develop]) { + await readBranchTip({ github, owner, repo, branch: name }) + } + + await assertBranchHoldsVersion({ github, owner, repo, branch, ref: branchSha, 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) { + if (!(await commitContainsBranch({ github, owner, repo, branch, ref: branchSha, sha: tagSha }))) { + 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.' + ) + } + core.info(`${version} is already published (${releaseUrl}) from ${branch}: nothing to publish again.`) + } + + const { merged: mergedBase, sha: baseSha } = await mergeInto({ + github, + core, + owner, + repo, + into: base, + head: branchSha, + message: mergeMessage(branch, base), + label: branch, + }) + + // base-sha is the commit the release is published at and built from: the merge commit, or the + // base's tip on a retry that found the merge done. The second merge takes that commit, not the + // base's name, so develop receives exactly what is being released even if the base moves on. + let mergedDevelop = null + if (develop === null) { + core.info(`develop-branch is empty: ${base} is not merged back into a develop branch.`) + } else { + mergedDevelop = ( + await mergeInto({ + github, + core, + owner, + repo, + into: develop, + head: baseSha, + message: `Merge ${base} back into ${develop} (${branch})`, + label: base, + }) + ).merged + } + + 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, + readHead, + mergeMessage, + isConflict, +} 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..b6eaad6 --- /dev/null +++ b/.github/lib/github.js @@ -0,0 +1,92 @@ +'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: the commit it points at. + * + * @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 a read fails for any reason but 404, so an API error is never taken for an + * absent tag. + * + * @returns {Promise} The commit the tag points at, through the tag object when the tag + * is annotated; null when there is no such tag. + */ +async function readTag({ github, owner, repo, version, what }) { + let ref + try { + ref = (await github.rest.git.getRef({ owner, repo, ref: `tags/${version}` })).data + } catch (error) { + if (isNotFound(error)) { + return null + } + + throw new Error(`Reading tag ${version} failed (HTTP ${error.status}): ${error.message}. ${what}`) + } + + if (ref.object.type !== 'tag') { + return ref.object.sha + } + + // An annotated tag is an object of its own; the commit is what it points at. + try { + return (await github.rest.git.getTag({ owner, repo, tag_sha: ref.object.sha })).data.object.sha + } catch (error) { + throw new Error(`Reading the tag object of ${version} failed (HTTP ${error.status}): ${error.message}. ${what}`) + } +} + +/** + * Whether a tag of the repository exists. + * + * @param {object} options Passed to readTag. + * + * @throws {Error} When the read fails for any reason but 404. + * + * @returns {Promise} Whether the tag exists. + */ +async function tagExists(options) { + return (await readTag(options)) !== null +} + +module.exports = { readRepository, isNotFound, readTag, 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 5e7ebbc..964c710 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,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..b6722a7 --- /dev/null +++ b/tests/actions/release-merge/release-merge.test.js @@ -0,0 +1,655 @@ +'use strict' + +const { describe, it } = require('node:test') +const assert = require('node:assert') + +const { + runMerge, + runPreflight, + validateVersion, + validateBranch, + readHead, + mergeMessage, + isConflict, +} = require('../../../.github/actions/release-merge/release-merge.js') +const { coreWithOutputs, octokitFor } = require('../../support/actions.js') + +/** + * The environment the manifest hands the merge step. + * + * @param {object} overrides Inputs to change. + * + * @returns {object} The environment. + */ +function mergeEnv(overrides = {}) { + return { + INPUT_BRANCH: 'release/M26.test', + INPUT_VERSION: '4.17.0', + INPUT_BASE_BRANCH: 'main', + INPUT_DEVELOP_BRANCH: 'develop', + GITHUB_REPOSITORY: 'stellarwp/plugin', + ...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 tips of the three branches, as a plugin repository has them after release prep. */ +const TIP = { + 'release/M26.test': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + main: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + develop: 'cccccccccccccccccccccccccccccccccccccccc', +} + +/** The merge commits GitHub answers with: the release on main, then main on develop. */ +const MERGED = { + main: 'dddddddddddddddddddddddddddddddddddddddd', + develop: 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', +} + +/** The commit a published 4.17.0 is tagged at, and the tag object of an annotated one. */ +const TAG_COMMIT = 'ffffffffffffffffffffffffffffffffffffffff' +const TAG_OBJECT = '1111111111111111111111111111111111111111' + +const REPO = '/repos/stellarwp/plugin' + +/** The repository paths the action reads and writes, as a real Octokit spells them. */ +const API = { + tag: `${REPO}/git/ref/tags%2F4.17.0`, + tagObject: `${REPO}/git/tags/${TAG_OBJECT}`, + releaseByTag: `${REPO}/releases/tags/4.17.0`, + branch: (name) => `${REPO}/branches/${encodeURIComponent(name)}`, + // The files are read at the release branch's tip, the commit, not at the branch by name. + file: (name, ref = TIP['release/M26.test']) => `${REPO}/contents/${name}?ref=${encodeURIComponent(ref)}`, + compare: (sha, ref = TIP['release/M26.test']) => `${REPO}/compare/${encodeURIComponent(`${ref}...${sha}`)}`, + merges: `${REPO}/merges`, +} + +const RELEASE_URL = 'https://github.com/stellarwp/plugin/releases/tag/4.17.0' + +/** 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: TAG_COMMIT, type: 'commit' } } }, + { method: 'GET', path: API.releaseByTag, body: { html_url: RELEASE_URL } }, +] + +/** + * Rules for the branches the action reads: each answers with its tip. + * + * @param {string[]} names The branches the repository has. + * + * @returns {object[]} One rule per branch. + */ +function branches(names = Object.keys(TIP)) { + return names.map((name) => ({ method: 'GET', path: API.branch(name), body: { name, commit: { sha: TIP[name] } } })) +} + +/** + * Rules for the files the action reads from the release branch, as the contents endpoint sends + * them. A file set to null is absent: no rule, so the read answers 404. + * + * @param {object} contents File name to contents; the fixture's defaults hold 4.17.0 everywhere. + * + * @returns {object[]} One rule per present file. + */ +function files(contents = {}) { + const all = { + '.puprc': JSON.stringify(PUPRC), + 'plugin.php': 'Version: 4.17.0\n', + 'readme.txt': 'Stable tag: 4.17.0\n', + ...contents, + } + + return Object.entries(all) + .filter(([, body]) => body !== null) + .map(([name, body]) => ({ + method: 'GET', + path: API.file(name), + body: { type: 'file', path: name, encoding: 'base64', content: Buffer.from(body).toString('base64') }, + })) +} + +/** What the merge endpoint answers: a merge commit, nothing to merge, or a refusal. */ +const MERGE = { + main: { status: 201, body: { sha: MERGED.main } }, + develop: { status: 201, body: { sha: MERGED.develop } }, + done: { status: 204 }, + conflict: { status: 409, body: { message: 'Merge conflict' } }, + moved: { status: 409, body: { message: 'Base branch was modified. Review and try the merge again.' } }, +} + +/** + * The rule for the merge endpoint, answering each request with the next entry. + * + * @param {object[]} responses What each merge request is answered with, in order. + * + * @returns {object} The rule. + */ +function merges(responses) { + return { method: 'POST', path: API.merges, responses } +} + +/** + * The rules of the common case: no tag, three branches, a prepared release branch, both merges made. + * + * @param {object} options + * @param {object[]} options.tag Rules for the tag and release reads; none for an untagged version. + * @param {string[]} options.branches The branches the repository has. + * @param {object} options.files Files of the release branch to change from the fixture. + * @param {object[]} options.merges What the merge requests are answered with. + * @param {object[]} options.extra Any other rule. + * + * @returns {object[]} The rules. + */ +function repositoryWith({ + tag = [], + branches: names = Object.keys(TIP), + files: contents = {}, + merges: answers = [MERGE.main, MERGE.develop], + extra = [], +} = {}) { + return [...tag, ...branches(names), ...files(contents), merges(answers), ...extra] +} + +/** + * Each request as `METHOD path`, for asserting what the action asked the API and in which order. + * + * @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 bodies of the merge requests, in order. + * + * @param {object[]} requests The requests recordingFetch recorded. + * + * @returns {object[]} `{base, head, commit_message}` per merge asked for. + */ +function mergesAsked(requests) { + return requests.filter((request) => request.method === 'POST' && request.path === API.merges).map((request) => request.body) +} + +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('readHead', () => { + it('reads a full commit hash, lower-cased, and an empty value as no check', () => { + assert.strictEqual(readHead(` ${TIP.main.toUpperCase()} `), TIP.main) + assert.strictEqual(readHead(''), null) + assert.strictEqual(readHead(undefined), null) + }) + + it('refuses anything but a full hash: a branch name would be read at run time again', () => { + for (const value of ['main', 'aaaaaaa', 'refs/heads/main', `${TIP.main}0`]) { + assert.throws(() => readHead(value), /is not a full commit hash.*github\.sha.*Nothing was merged/s, value) + } + }) + }) + + 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 409 GitHub answers a conflicting merge with', () => { + assert.strictEqual(isConflict({ status: 409, message: 'Merge conflict' }), true) + }) + + it('does not read the 409 for a base that moved, or any other failure, as a conflict', () => { + assert.strictEqual(isConflict({ status: 409, message: 'Base branch was modified. Review and try the merge again.' }), false) + assert.strictEqual(isConflict({ status: 404, message: 'Base does not exist' }), false) + assert.strictEqual(isConflict({ status: 422, message: 'Validation Failed' }), false) + }) + }) + + describe('runPreflight', () => { + it('finds nothing when the version was never tagged', async () => { + const { github, requests } = await octokitFor([]) + + const result = await runPreflight({ github, owner: 'stellarwp', repo: 'plugin', version: '4.17.0' }) + + assert.deepStrictEqual(result, { tagged: false, tagSha: null, releaseUrl: null }) + assert.deepStrictEqual(requested(requests), [`GET ${API.tag}`]) + }) + + it('finds the published release and the tagged commit 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, tagSha: TAG_COMMIT, releaseUrl: RELEASE_URL }) + assert.deepStrictEqual(requested(requests), [`GET ${API.tag}`, `GET ${API.releaseByTag}`]) + }) + + it('reads the commit behind an annotated tag, since the ref points at the tag object', async () => { + const { github, requests } = await octokitFor([ + { method: 'GET', path: API.tag, body: { ref: 'refs/tags/4.17.0', object: { sha: TAG_OBJECT, type: 'tag' } } }, + { method: 'GET', path: API.tagObject, body: { sha: TAG_OBJECT, object: { sha: TAG_COMMIT, type: 'commit' } } }, + PUBLISHED[1], + ]) + + const result = await runPreflight({ github, owner: 'stellarwp', repo: 'plugin', version: '4.17.0' }) + + assert.strictEqual(result.tagSha, TAG_COMMIT) + assert.deepStrictEqual(requested(requests), [`GET ${API.tag}`, `GET ${API.tagObject}`, `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, then the merged commit back into develop, as two merge commits', async (t) => { + const { github, requests } = await octokitFor(repositoryWith()) + const { core, outputs, summary } = await coreWithOutputs(t) + + await runMerge({ github, core, env: mergeEnv() }) + + assert.deepStrictEqual(requested(requests), [ + `GET ${API.tag}`, + `GET ${API.branch('release/M26.test')}`, + `GET ${API.branch('main')}`, + `GET ${API.branch('develop')}`, + `GET ${API.file('.puprc')}`, + `GET ${API.file('plugin.php')}`, + `GET ${API.file('readme.txt')}`, + `POST ${API.merges}`, + `POST ${API.merges}`, + ]) + assert.deepStrictEqual(mergesAsked(requests), [ + // The tip the version check read, not the branch by name: what was checked is what is merged. + { base: 'main', head: TIP['release/M26.test'], commit_message: 'Merge release/M26.test into main' }, + // The commit just made on main, not main by name: develop gets exactly what is released. + { base: 'develop', head: MERGED.main, commit_message: 'Merge main back into develop (release/M26.test)' }, + ]) + assert.deepStrictEqual(outputs(), { + branch: 'release/M26.test', + version: '4.17.0', + 'base-branch': 'main', + // The merge commit: what the caller checks out, builds from and tags. + 'base-sha': MERGED.main, + 'develop-branch': 'develop', + conflict: '', + }) + assert.match(summary(), /merged `release\/M26\.test` into `main`, merged `main` into `develop`/) + }) + + it('checks, compares and merges the tip it read, so a commit pushed to the branch meanwhile is not merged', async (t) => { + // The branch by name answers with a later commit that holds another version, as a push between + // the tip read and the merge would leave it. Nothing the action does may reach it. + const pushed = '9999999999999999999999999999999999999999' + const byName = ['.puprc', 'plugin.php', 'readme.txt'].map((name) => ({ + method: 'GET', + path: API.file(name, 'release/M26.test'), + body: { + type: 'file', + path: name, + encoding: 'base64', + content: Buffer.from(name === '.puprc' ? JSON.stringify(PUPRC) : 'Version: 4.18.0\nStable tag: 4.18.0\n').toString('base64'), + }, + })) + const { github, requests } = await octokitFor( + repositoryWith({ + tag: PUBLISHED, + extra: [ + ...byName, + { method: 'GET', path: API.compare(TAG_COMMIT), body: { status: 'ahead' } }, + { method: 'GET', path: API.compare(TAG_COMMIT, 'release/M26.test'), body: { status: 'diverged' } }, + { method: 'GET', path: API.branch('release/M26.test'), body: { commit: { sha: pushed } } }, + ], + }) + ) + const { core } = await coreWithOutputs(t) + + await runMerge({ github, core, env: mergeEnv() }) + + const tip = TIP['release/M26.test'] + assert.deepStrictEqual( + requested(requests).filter((line) => line.includes('/contents/') || line.includes('/compare/')), + [ + `GET ${API.file('.puprc', tip)}`, + `GET ${API.file('plugin.php', tip)}`, + `GET ${API.file('readme.txt', tip)}`, + `GET ${API.compare(TAG_COMMIT, tip)}`, + ], + 'every read names the tip, never the branch' + ) + assert.strictEqual(mergesAsked(requests)[0].head, tip, 'the merge takes the tip that was checked') + assert.ok(!JSON.stringify(requests).includes(pushed), 'the later commit is never read or merged') + }) + + it('merges when head is the commit the branch is at', async (t) => { + const { github, requests } = await octokitFor(repositoryWith()) + const { core, outputs } = await coreWithOutputs(t) + + await runMerge({ github, core, env: mergeEnv({ INPUT_HEAD: TIP['release/M26.test'] }) }) + + assert.strictEqual(mergesAsked(requests).length, 2) + assert.strictEqual(outputs()['base-sha'], MERGED.main) + }) + + it('refuses a branch that moved after the run started, before reading a file or merging', async (t) => { + // The run was started at `head`; a push during the wait for approval moved the branch to TIP. + const head = '9999999999999999999999999999999999999999' + const { github, requests } = await octokitFor(repositoryWith()) + const { core, outputs } = await coreWithOutputs(t) + + await assert.rejects( + runMerge({ github, core, env: mergeEnv({ INPUT_HEAD: head }) }), + /release\/M26\.test is at aaaa.*, not at 9999.*, the commit this run was started on.*Start the deploy again.*Nothing was merged/s + ) + + assert.deepStrictEqual(requested(requests), [`GET ${API.tag}`, `GET ${API.branch('release/M26.test')}`]) + assert.deepStrictEqual(mergesAsked(requests), []) + assert.deepStrictEqual(outputs(), {}) + }) + + it('refuses a head that is not a full commit hash before asking the API anything', async (t) => { + const { github, requests } = await octokitFor(repositoryWith()) + const { core } = await coreWithOutputs(t) + + await assert.rejects(runMerge({ github, core, env: mergeEnv({ INPUT_HEAD: 'release/M26.test' }) }), /head 'release\/m26\.test' is not a full commit hash/) + assert.deepStrictEqual(requests, []) + }) + + it('merges only into the base when develop-branch is empty, for a repository without develop', async (t) => { + const { github, requests } = await octokitFor(repositoryWith({ branches: ['release/M26.test', 'main'], merges: [MERGE.main] })) + const { core, outputs, summary, logged } = await coreWithOutputs(t) + + await runMerge({ github, core, env: mergeEnv({ INPUT_DEVELOP_BRANCH: '' }) }) + + assert.ok(!requested(requests).some((line) => line.includes('develop')), requested(requests).join('\n')) + assert.strictEqual(mergesAsked(requests).length, 1) + assert.strictEqual(outputs()['base-sha'], MERGED.main) + assert.strictEqual(outputs()['develop-branch'], '') + assert.strictEqual(outputs().conflict, '') + assert.match(logged(), /develop-branch is empty/) + assert.match(summary(), /no develop branch/) + }) + + it('does nothing on a retry after a late failure: both merges answer already merged', async (t) => { + // The publish step tagged the first run's merge commit, so the retry finds the tag with its + // release, and main's tip is that commit. + const { github, requests } = await octokitFor( + repositoryWith({ + tag: [ + { method: 'GET', path: API.tag, body: { ref: 'refs/tags/4.17.0', object: { sha: MERGED.main, type: 'commit' } } }, + PUBLISHED[1], + ], + merges: [MERGE.done, MERGE.done], + extra: [{ method: 'GET', path: API.compare(MERGED.main), body: { status: 'ahead' } }], + }).map((rule) => (rule.path === API.branch('main') ? { ...rule, body: { name: 'main', commit: { sha: MERGED.main } } } : rule)) + ) + const { core, outputs, logged } = await coreWithOutputs(t) + + await runMerge({ github, core, env: mergeEnv() }) + + assert.strictEqual(mergesAsked(requests).length, 2) + assert.match(logged(), /main already has release\/M26\.test: nothing to merge/) + assert.match(logged(), /develop already has main: nothing to merge/) + assert.strictEqual(outputs().conflict, '') + // Nothing was merged, so the commit to publish at is the base's tip, the one holding the release. + assert.strictEqual(outputs()['base-sha'], MERGED.main) + // The second merge still asks for that commit, which develop already has. + assert.strictEqual(mergesAsked(requests)[1].head, MERGED.main) + }) + + it('says so when the release is already published from this branch, so the publish has nothing to do', async (t) => { + const { github, requests } = await octokitFor( + repositoryWith({ + tag: PUBLISHED, + extra: [{ method: 'GET', path: API.compare(TAG_COMMIT), body: { status: 'ahead' } }], + }) + ) + const { core, logged } = await coreWithOutputs(t) + + await runMerge({ github, core, env: mergeEnv() }) + + assert.match(logged(), /4\.17\.0 is already published \(.*releases\/tag\/4\.17\.0\) from release\/M26\.test: nothing to publish again/) + assert.ok(requested(requests).includes(`GET ${API.compare(TAG_COMMIT)}`), requested(requests).join('\n')) + assert.strictEqual(mergesAsked(requests).length, 2) + }) + + it('refuses a published version whose tag does not contain the branch: the branch was not bumped', async (t) => { + // 4.17.0 was released from main before this branch was cut, and the branch still says 4.17.0. + const { github, requests } = await octokitFor( + repositoryWith({ + tag: PUBLISHED, + extra: [{ method: 'GET', path: API.compare(TAG_COMMIT), body: { status: 'diverged' } }], + }) + ) + const { core } = await coreWithOutputs(t) + + await assert.rejects( + runMerge({ github, core, env: mergeEnv() }), + /tag 4\.17\.0 already exists and does not contain release\/M26\.test.*new version.*Nothing was merged/s + ) + + assert.deepStrictEqual(mergesAsked(requests), []) + }) + + it('prints the MERGE_CONFLICT marker and merges nothing when the release conflicts with the base', async (t) => { + const { github, requests } = await octokitFor(repositoryWith({ merges: [MERGE.conflict] })) + const { core, outputs, logged } = await coreWithOutputs(t) + + await assert.rejects( + runMerge({ github, core, env: mergeEnv() }), + /release\/M26\.test into main stopped on a conflict.*Nothing was merged into 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(mergesAsked(requests).length, 1, 'develop was never asked for') + }) + + it('prints the marker and names develop when the merged base conflicts with develop', async (t) => { + const { github, requests } = await octokitFor(repositoryWith({ merges: [MERGE.main, MERGE.conflict] })) + const { core, outputs, logged } = await coreWithOutputs(t) + + await assert.rejects( + runMerge({ github, core, env: mergeEnv() }), + /main into develop stopped on a conflict.*merges already done are kept.*Nothing was merged into 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.deepStrictEqual(mergesAsked(requests).map((merge) => merge.base), ['main', 'develop']) + }) + + it('fails without the marker when GitHub refuses the merge because the base moved', async (t) => { + const { github } = await octokitFor(repositoryWith({ merges: [MERGE.moved] })) + const { core, outputs, logged } = await coreWithOutputs(t) + + await assert.rejects( + runMerge({ github, core, env: mergeEnv() }), + /main moved while the merge was being made.*Base branch was modified.*Run this step again/s + ) + + assert.doesNotMatch(logged(), /MERGE_CONFLICT/) + assert.strictEqual(outputs().conflict, undefined) + }) + + it('refuses a version the branch does not hold, naming the file, before merging anything', async (t) => { + const { github, requests } = await octokitFor(repositoryWith()) + const { core } = await coreWithOutputs(t) + + await assert.rejects( + runMerge({ github, core, env: mergeEnv({ INPUT_VERSION: '4.18.0' }) }), + /release\/M26\.test holds 4\.17\.0 in plugin\.php, not 4\.18\.0.*prepared with.*Nothing was merged/s + ) + + assert.deepStrictEqual(mergesAsked(requests), []) + }) + + it('checks every version file .puprc lists, so a half-prepared branch is refused too', async (t) => { + const { github, requests } = await octokitFor(repositoryWith({ files: { 'readme.txt': 'Stable tag: 4.16.0\n' } })) + const { core } = await coreWithOutputs(t) + + await assert.rejects(runMerge({ github, core, env: mergeEnv() }), /release\/M26\.test holds 4\.16\.0 in readme\.txt, not 4\.17\.0/) + assert.deepStrictEqual(mergesAsked(requests), []) + }) + + it("reads the version files at the release branch's tip, not from the base", async (t) => { + const { github, requests } = await octokitFor(repositoryWith()) + const { core } = await coreWithOutputs(t) + + await runMerge({ github, core, env: mergeEnv() }) + + const reads = requests.filter((request) => request.path.startsWith(`${REPO}/contents/`)) + assert.strictEqual(reads.length, 3) + assert.ok(reads.every((request) => request.path.endsWith(`?ref=${TIP['release/M26.test']}`)), requested(reads).join('\n')) + }) + + it('refuses a branch whose version file does not match the .puprc regex', async (t) => { + const { github } = await octokitFor(repositoryWith({ files: { 'readme.txt': 'No stable tag here\n' } })) + const { core } = await coreWithOutputs(t) + + await assert.rejects( + runMerge({ github, core, env: mergeEnv() }), + /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 puprc = { paths: { versions: [{ file: 'plugin.php', regex: '(Version: )(?<' }] } } + const { github, requests } = await octokitFor(repositoryWith({ files: { '.puprc': JSON.stringify(puprc) } })) + const { core } = await coreWithOutputs(t) + + await assert.rejects( + runMerge({ github, core, env: mergeEnv() }), + /regex \.puprc gives for plugin\.php \(\(Version: \)\(\?<\) is not one JavaScript accepts.*Nothing was merged/s + ) + assert.deepStrictEqual(mergesAsked(requests), []) + }) + + it('refuses a branch whose version file is missing, naming it', async (t) => { + const { github } = await octokitFor(repositoryWith({ files: { 'readme.txt': null } })) + const { core } = await coreWithOutputs(t) + + await assert.rejects( + runMerge({ github, core, env: mergeEnv() }), + /readme\.txt, which \.puprc lists under paths\.versions, is not on release\/M26\.test/ + ) + }) + + it('refuses a branch without a .puprc, since the version it holds cannot be read', async (t) => { + const { github } = await octokitFor(repositoryWith({ files: { '.puprc': null } })) + const { core } = await coreWithOutputs(t) + + await assert.rejects(runMerge({ github, core, env: mergeEnv() }), /No \.puprc on the branch.*Nothing was merged/s) + }) + + it('refuses a branch the repository does not have, before reading a file or merging', async (t) => { + const { github, requests } = await octokitFor(repositoryWith({ branches: ['main', 'develop'] })) + const { core } = await coreWithOutputs(t) + + await assert.rejects( + runMerge({ github, core, env: mergeEnv() }), + /'release\/M26\.test' is not a branch of stellarwp\/plugin\. Nothing was merged/ + ) + + assert.deepStrictEqual(requested(requests), [`GET ${API.tag}`, `GET ${API.branch('release/M26.test')}`]) + }) + + it('refuses a tag without a release before reading any branch', async (t) => { + const { github, requests } = await octokitFor(repositoryWith({ tag: [PUBLISHED[0]] })) + const { core } = await coreWithOutputs(t) + + await assert.rejects(runMerge({ github, core, env: mergeEnv() }), /no published release/) + + assert.deepStrictEqual(requested(requests), [`GET ${API.tag}`, `GET ${API.releaseByTag}`]) + }) + + it('refuses the same branch twice, which would merge a branch into itself, before asking the API anything', async (t) => { + const { github, requests } = await octokitFor(repositoryWith()) + const { core } = await coreWithOutputs(t) + + await assert.rejects( + runMerge({ github, core, env: mergeEnv({ INPUT_DEVELOP_BRANCH: 'main' }) }), + /base-branch and develop-branch are both 'main'/ + ) + await assert.rejects(runMerge({ github, core, env: mergeEnv({ INPUT_BRANCH: 'develop' }) }), /'develop' is also the develop branch/) + assert.deepStrictEqual(requests, []) + }) + + it('fails when GITHUB_REPOSITORY is missing, since it does not know whose branches to merge', async (t) => { + const { github, requests } = await octokitFor(repositoryWith()) + const { core } = await coreWithOutputs(t) + + await assert.rejects(runMerge({ github, core, env: mergeEnv({ GITHUB_REPOSITORY: '' }) }), /GITHUB_REPOSITORY is not set/) + assert.deepStrictEqual(requests, []) + }) + }) +}) 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 a579552..fd0fb50 100644 --- a/tests/support/actions.js +++ b/tests/support/actions.js @@ -32,10 +32,13 @@ const path = require('node:path') * @returns {Response} The response for the fetch to return. */ function jsonResponse({ status = 200, body = {}, headers = {} }) { - // A 204 or 304 has no body, and Response refuses one. - const content = status === 204 || status === 304 ? null : JSON.stringify(body) + // A 204 carries no body, and Response refuses one: the merge endpoint answers it when the base + // already contains the head. + if (status === 204 || status === 205 || status === 304) { + return new Response(null, { status, headers }) + } - return new Response(content, { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers }, }) @@ -311,6 +314,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. * @@ -334,6 +369,7 @@ module.exports = { coreWithOutputs, recordingFetch, recordingExec, + recordedRealExec, readOutputs, isLogWrite, }