diff --git a/.github/actions/move-git-tags/README.md b/.github/actions/move-git-tags/README.md new file mode 100644 index 0000000..b43cb7d --- /dev/null +++ b/.github/actions/move-git-tags/README.md @@ -0,0 +1,135 @@ +# move-git-tags + +Creates or moves the named git tags so that they point at a commit. A tag that does not exist is +created, one that does is repointed. + +It knows nothing about versions. [resolve-floating-tags](../resolve-floating-tags/) decides which +floating version tags a release should own and this action writes them: + +```yaml +- name: Resolve the floating tags for this release + id: floating + uses: stellarwp/plugin-toolbox/.github/actions/resolve-floating-tags@v1 + with: + tag: ${{ github.event.release.tag_name }} + +- name: Move them onto the released commit + uses: stellarwp/plugin-toolbox/.github/actions/move-git-tags@v1 + with: + tags: ${{ steps.floating.outputs.tags }} +``` + +Any tag name works, so it also moves a `stable` or `latest` pointer, or a release tag that has to be +repointed at a build commit. `sha` defaults to the commit the run is for, so most callers pass only +the names: + +```yaml +- uses: stellarwp/plugin-toolbox/.github/actions/move-git-tags@v1 + with: + tags: stable +``` + +## Inputs + +| Input | Required | Default | What it does | +|---|---|---|---| +| `tags` | yes | | Tag names to create or move, separated by spaces or newlines, e.g. `v1 v1.2` | +| `sha` | no | the commit the run is for | The commit the tags should point at | +| `repository` | no | current repo | The `/` to write the tags in | +| `token` | no | `github.token` | Writes the tag refs. See [Permissions](#permissions) | + +## Outputs + +| Output | Example | What it is | +|---|---|---| +| `created` | `v1.2` | Tags that did not exist and were created | +| `moved` | `v1` | Tags that already existed and were repointed | + +## An empty tag list is not an error + +`tags: ''` moves nothing and succeeds, so a caller feeding it another step's output needs no `if:` +of its own. resolve-floating-tags returns an empty `tags` whenever the release should own no +floating tags, and the job stays green. + +## Permissions + +The job needs `contents: write`, which is not the default when a workflow sets +`permissions: contents: read` at the top: + +```yaml +jobs: + move-floating-tags: + permissions: + contents: write +``` + +`github.token` is the token of the workflow that runs this action, scoped to that repository, so a +repo calling this action writes its own tags with its own token and needs no secret. + +Pass a PAT or app token as `token` instead in either of two cases. One is a ruleset that protects the +tags in a way `github.token` cannot satisfy; a tag protection rule that blocks the default token makes +the API call fail rather than silently skip. + +The other is a repo whose own workflows have to react to the tag. GitHub creates no workflow run for +an event made with `GITHUB_TOKEN`, apart from `workflow_dispatch` and `repository_dispatch`, so a +workflow listening on `push:` for `tags:` will not see a tag this action moved. + +## Why the API and not git push + +Writing the refs through the API rather than `git push --force` means the tag name never disappears +for a moment, which is what keeps a GitHub Release attached to it. It also means the job needs no +checkout of the repo to move a tag. + +Each tag is read with `git.getRef` before being written, instead of trying `git.updateRef` and +creating the tag when that fails. An update also fails for a token without permission or a protected +tag, and a fallback would report those as a successful creation. Only a 404 is read as "does not +exist"; any other status fails the job. + +`getRef` is the single-ref endpoint on purpose. `listMatchingRefs` matches by prefix, so it answers +for `tags/v1` whenever a `v1.0.0` tag exists even when there is no `v1` to update: + +```console +$ gh api repos/stellarwp/changelogger/git/refs/tags/0.1 --jq 'map(.ref)' +["refs/tags/0.1.0","refs/tags/0.10.0"] + +$ gh api repos/stellarwp/changelogger/git/ref/tags/0.1 +gh: Not Found (HTTP 404) +``` + +The two calls also spell the ref differently: `getRef` and `updateRef` take `tags/v1`, while +`createRef` takes the full `refs/tags/v1`. + +## Tag names + +Pass tag names, not refs. `refs/tags/v1` is refused, because writing it would create a tag named +`refs/tags/v1`. + +A name has to start with a letter or digit and hold only letters, digits, `.`, `_`, `-` and `/`. +`..` is refused as well. Each name goes into an API path, so anything else stops the job instead of +being sent. + +## Lightweight tags only + +The refs are written straight to a commit, so these are lightweight tags with no tagger, date or +message of their own. A floating tag is repointed every time a release claims it, so an annotated tag +object would be replaced each time. + +A release tag that has to be annotated or signed is created by whoever cuts the release, not here. + +## How it runs + +The logic is in `move.js`, run by [actions/github-script](https://github.com/actions/github-script), +which supplies the Node runtime and an Octokit already authenticated with `token`. There is no +dependency to install and no bundle to build: the file in the repository is the file that runs, so a +change to it is reviewable as a diff. + +The script is loaded by path rather than inlined in the manifest, because github-script's `require` +resolves against the workspace of the repository being built, not this action's directory. The path +comes from `${{ github.action_path }}`. + +Inputs reach the script through `process.env`, never by being interpolated into it, so a tag name is +data and cannot become part of the program. + +`tests/actions/move-git-tags/move.test.js` covers the rules above. It drives the +real Octokit with the network replaced, so the assertions are the requests this action would +send. Run `npm install` once, then `node --test`. diff --git a/.github/actions/move-git-tags/action.yml b/.github/actions/move-git-tags/action.yml new file mode 100644 index 0000000..954f8d3 --- /dev/null +++ b/.github/actions/move-git-tags/action.yml @@ -0,0 +1,63 @@ +# Creates or moves git tags through the GitHub API. Knows nothing about versions: pair it with +# .github/actions/resolve-floating-tags to move floating version tags, or hand it any tag name. +# +# The work lives in move.js. actions/github-script runs it with a pre-authenticated Octokit, so +# there is no dependency to install and nothing to build: the file in the repository is the file +# that runs. +name: Move git tags +description: Creates or moves the named git tags so that they point at a commit + +inputs: + tags: + description: >- + The tags to create or move, separated by spaces or newlines, e.g. `v1 v1.2`. An empty value + moves nothing and succeeds, so a caller passing another step's output needs no `if:` of its + own. Names are tag names, not refs: pass `v1`, not `refs/tags/v1`. + required: true + sha: + description: >- + The commit the tags should point at. Defaults to the commit the workflow is running for, which + on a `release` event is the commit the release tag names. + required: false + default: '' + repository: + description: 'The `/` to write the tags in. Defaults to the current repository.' + required: false + default: '' + token: + description: >- + Token used to write the tag refs. It needs `contents: write` on the repository, so the job + needs that permission too. Pass a PAT or app token instead when a ruleset protects the tags in + a way the default token cannot satisfy. + required: false + default: ${{ github.token }} + +outputs: + created: + description: 'The tags that did not exist and were created, space separated.' + value: ${{ steps.move.outputs.created }} + moved: + description: 'The tags that already existed and were repointed, space separated.' + value: ${{ steps.move.outputs.moved }} + +runs: + using: 'composite' + steps: + - name: Create or move the tags + id: move + 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 a tag name is data and + # never part of the program. + INPUT_TAGS: ${{ inputs.tags }} + INPUT_SHA: ${{ inputs.sha }} + INPUT_REPOSITORY: ${{ inputs.repository }} + with: + github-token: ${{ inputs.token }} + script: | + const { run } = require(`${process.env.ACTION_PATH}/move.js`) + await run({ github, core, env: process.env }) diff --git a/.github/actions/move-git-tags/move.js b/.github/actions/move-git-tags/move.js new file mode 100644 index 0000000..a476ad3 --- /dev/null +++ b/.github/actions/move-git-tags/move.js @@ -0,0 +1,131 @@ +'use strict' + +/** + * Creates or moves git tags so that they point at a commit. + * + * parseTagNames is pure and holds the validation. moveTags takes an Octokit and does the writing, + * so a test can pass a stand-in and assert the calls. + */ + +/** A plain tag name: no spaces, no leading dash or dot, nothing that could reshape a request. */ +const PLAIN_TAG_NAME = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/ + +/** + * Splits the `tags` input and checks every name. + * + * @param {string} tags Tag names separated by spaces or newlines. + * + * @throws {Error} When a name is not a plain tag name. + * + * @returns {string[]} The names, in the order given. + */ +function parseTagNames(tags) { + const names = String(tags ?? '') + .trim() + .split(/\s+/) + .filter(Boolean) + + for (const name of names) { + // A full ref would otherwise be written as a tag literally named refs/tags/v1. + if (name.startsWith('refs/')) { + throw new Error(`Refusing to write '${name}': pass the tag name, e.g. '${name.split('/').pop()}'.`) + } + + if (!PLAIN_TAG_NAME.test(name) || name.includes('..')) { + throw new Error(`Refusing to write '${name}': it is not a plain tag name.`) + } + } + + return names +} + +/** + * Points each tag at a commit, creating the ones that do not exist. + * + * The ref is read first rather than trying an update and creating on failure. An update also fails + * for a token without permission or a protected tag, and treating that as "does not exist yet" + * would report a refused write as a successful creation. getRef is the single-ref endpoint, which + * answers 404 for a missing ref; the matching-refs endpoint matches by prefix instead, and would + * answer for `tags/v1` whenever a v1.0.0 tag exists. + * + * The refs are written through the API rather than by pushing, so a tag name never disappears for a + * moment, which is what keeps a GitHub Release attached to it. + * + * @param {object} github An Octokit, as actions/github-script supplies it. + * @param {object} core The @actions/core toolkit, for the log. + * @param {string} owner The repository owner. + * @param {string} repo The repository name. + * @param {string[]} names The tag names to point at `sha`, already validated. + * @param {string} sha The commit the tags should point at. + * + * @throws {Error} When a write fails, or a read fails with anything but a 404. + * + * @returns {Promise<{created: string[], moved: string[]}>} Which names were created, and which + * already existed and were repointed. + */ +async function moveTags({ github, core, owner, repo, names, sha }) { + const created = [] + const moved = [] + + for (const name of names) { + let exists = true + + try { + // getRef takes the ref without its `refs/` prefix; createRef takes the full ref below. + await github.rest.git.getRef({ owner, repo, ref: `tags/${name}` }) + } catch (error) { + if (error.status !== 404) { + throw error + } + + exists = false + } + + if (exists) { + await github.rest.git.updateRef({ owner, repo, ref: `tags/${name}`, sha, force: true }) + core.info(`Moved ${name} to ${sha}.`) + moved.push(name) + } else { + await github.rest.git.createRef({ owner, repo, ref: `refs/tags/${name}`, sha }) + core.info(`Created ${name} at ${sha}.`) + created.push(name) + } + } + + return { created, moved } +} + +/** + * Moves the tags named by the inputs and writes the action's outputs. + * + * @param {object} github An Octokit, as actions/github-script supplies it. + * @param {object} core The @actions/core toolkit, for the log and the outputs. + * @param {object} env The environment the action manifest put its inputs in. + * + * @throws {Error} When a tag name is not a plain tag name, or a write fails. + * + * @returns {Promise} Resolves once every tag has been written and the outputs are set. + */ +async function run({ github, core, env }) { + const repository = env.INPUT_REPOSITORY || env.GITHUB_REPOSITORY + const [owner, repo] = repository.split('/') + const sha = env.INPUT_SHA || env.GITHUB_SHA + const names = parseTagNames(env.INPUT_TAGS) + + // An empty list succeeds, so a caller feeding this another step's output needs no `if:` of + // its own. + if (names.length === 0) { + core.info('No tags to move.') + core.setOutput('created', '') + core.setOutput('moved', '') + + return + } + + const { created, moved } = await moveTags({ github, core, owner, repo, names, sha }) + + core.setOutput('created', created.join(' ')) + core.setOutput('moved', moved.join(' ')) +} + +module.exports = { run, moveTags, parseTagNames, PLAIN_TAG_NAME } diff --git a/.github/actions/resolve-floating-tags/README.md b/.github/actions/resolve-floating-tags/README.md new file mode 100644 index 0000000..9c4ec98 --- /dev/null +++ b/.github/actions/resolve-floating-tags/README.md @@ -0,0 +1,265 @@ +# resolve-floating-tags + +Works out which floating version tags a released version should own. `1.2.3` owns `v1` and `v1.2`. + +It writes nothing. Pair it with [move-git-tags](../move-git-tags/), which takes its `tags` output +as input: + +```yaml +- name: Resolve the floating tags for this release + id: floating + uses: stellarwp/plugin-toolbox/.github/actions/resolve-floating-tags@v1 + with: + tag: ${{ github.event.release.tag_name }} + +- name: Move them onto the released commit + uses: stellarwp/plugin-toolbox/.github/actions/move-git-tags@v1 + with: + tags: ${{ steps.floating.outputs.tags }} +``` + +The two are separate so that either half can be used alone, and so that a repo can put its own steps +between them. See [Building between the two steps](#building-between-the-two-steps). + +## Inputs + +| Input | Required | Default | What it does | +|---|---|---|---| +| `tag` | yes | | The release tag, with or without a leading `v`, e.g. `1.2.3` or `v1.2.3` | +| `levels` | no | `'major minor'` | Which tags to resolve: `major` for `v1`, `minor` for `v1.2` | +| `allow-prereleases` | no | `'false'` | Let a prerelease own a tag whose line has had no stable release. See [Prereleases](#prereleases) | +| `repository` | no | current repo | The `/` whose tags and releases to read | +| `token` | no | `github.token` | Reads the repo's tags, and the prerelease flag of the tag passed in. Read access is enough | + +## Outputs + +| Output | Example | What it is | +|---|---|---| +| `tags` | `v1 v1.2` | The floating tags this version should own, shortest first. Empty when it should own none | +| `skipped` | `v1` | Tags a newer release of the same line already owns | +| `version` | `1.2.3` | The version without its leading `v`. Empty when the tag was rejected | + +## Permissions + +Reading the tags and the release needs no more than `contents: read`, which is what a workflow has by +default. A workflow that sets `permissions: {}` has to grant it. + +`github.token` is the token of the workflow that runs this action, scoped to that repository, so a +repo calling this action reads its own tags with its own token and needs no secret. + +## Each tag is resolved on its own + +A tag is resolved only when no stable release of **its own line** is newer than the version being +released. The two tags are decided separately, so a release often takes one and not the other. + +Releases 1.2.4 and 1.3.0 exist, so `v1` points at 1.3.0. Publishing 1.2.5, a patch on the older +line: + +| Tag | Line it covers | Newest in that line | Result | +|---|---|---|---| +| `v1` | every 1.x | 1.3.0 | stays at 1.3.0 | +| `v1.2` | every 1.2.x | 1.2.5, the release being published | moves to 1.2.5 | + +So consumers pinned to `@v1.2` get the patch, and consumers pinned to `@v1` keep 1.3.0 rather than +being moved back to older code. `v1` appears in `skipped` and `tags` holds `v1.2` alone. + +Publishing out of order follows the same rule. Cutting 1.1.1 while `v1` points at 1.2.0 moves `v1.1` +and leaves `v1` alone. + +A newer **major** never holds a lower one back, because it is not in the same line. With 2.1.0 +released, publishing 1.2.4 as the newest 1.x still resolves both `v1` and `v1.2`. Someone pinned to +`@v1` asked for the 1.x line, so a backport on it goes to them. + +## Hotfix versions with a fourth part + +A fourth part is read as another release of the same minor line, not a line of its own. A repo that +adds one only when a hotfix exists needs no configuration for it: + +| Releases | `v1.2` points at | `v1` points at | +|---|---|---| +| 1.2.3 | 1.2.3 | 1.2.3 | +| 1.2.3, 1.2.3.1 | 1.2.3.1 | 1.2.3.1 | +| 1.2.3, 1.2.3.1, 1.2.4 | 1.2.4 | 1.2.4 | + +Publishing the hotfix 1.2.3.1 moves `v1.2` to it, because it is the newest release of the 1.2 line. +Re-publishing 1.2.3 afterwards moves nothing, because the hotfix is newer. + +There is no `patch` level and no `v1.2.3` tag. A hotfix is the newest patch of its minor, which the +line comparison already handles. + +## Version numbers this accepts + +At least three dot-separated numbers, with or without a leading `v`. Three or four parts are both +ordinary; `1.2.10.0` sorts above `1.2.9.9` as it should. + +Three is the floor so that the deepest floating tag is always a shorter name than the release tag +itself. A release tagged `1.2` would want a `v1.2` floating tag, which on a `v`-prefixed repo is the +release tag. A two-part tag resolves nothing and says so with a notice. + +A release tag is accepted either way, `1.2.3` or `v1.2.3`, so a repo does not have to settle its +convention before this works. Both resolve to `v1` and `v1.2`: the floating tags always carry the +`v`, because that is what a `uses:` line resolves. Mixed conventions across a repo's history are +fine, since every version is compared with the `v` stripped. + +## Versions below 1.0.0 + +A 0.x release resolves `v0` and its own minor tag: 0.1.2 resolves `v0` and `v0.1`, and 0.0.5 resolves +`v0` and `v0.0`. The lines are compared as any others are: a 0.1.2 backport published while 0.2.0 is +out moves `v0.1` and leaves `v0` on 0.2.0. + +Semver says of 0.y.z only that anything may change at any time, so a 0.x minor is free to break +where a 1.x minor is not. `v0` therefore crosses breaking changes and `v1` does not. + +Both tags are resolved for a 0.x release by default. `levels: minor` resolves the minor tag and no +`v0`, for a repo that wants the tags it publishes to stay inside one minor. + +A repo keeping the default offers a `v0` that crosses every 0.x minor. A +`uses: /@v0` in someone else's workflow moves from 0.1.9 to 0.2.0 along with whatever +that minor changed. + +## Prereleases + +A prerelease is skipped by default, and it is detected without the caller passing anything. + +Two signals are read, because either can occur alone. A tag with a suffix, `1.3.0-rc.1`, is a +prerelease on its own. A tag that is a plain `1.3.0` but marked as a prerelease on GitHub is not +visibly different, so the release behind the `tag` input is asked about directly. + +Build metadata is not a prerelease. `1.4.0+build.7` is a stable 1.4.0 and owns the tags a stable +1.4.0 would. The metadata is not part of a tag name, so it resolves `v1` and `v1.4`. + +`allow-prereleases: 'true'` lets a prerelease own a floating tag, but only one whose line has had no +stable release yet. A prerelease never takes a tag away from a stable release. + +Publishing 1.4.0-rc.1 while the newest stable release is 1.3.0: + +| Tag | Line it covers | Stable release in that line | Result | +|---|---|---|---| +| `v1` | every 1.x | 1.3.0 | stays at 1.3.0 | +| `v1.4` | every 1.4.x | none yet | moves to 1.4.0-rc.1 | + +So `@v1.4` is how someone opts into testing the 1.4 line before it ships, and `@v1` keeps handing +everyone else the newest stable release. + +Each rc replaces the one before it on `v1.4`, and 1.4.0 then replaces the last rc. Only stable +releases are counted when finding the newest in a line, so rc.1 does not block rc.2, and neither rc +blocks 1.4.0: + +| Publishing | Stable releases so far | `v1` | `v1.4` | +|---|---|---|---| +| 1.4.0-rc.1 | 1.3.0 | stays at 1.3.0 | created, at rc.1 | +| 1.4.0-rc.2 | 1.3.0 | stays at 1.3.0 | moves to rc.2, replacing rc.1 | +| 1.4.0 | 1.3.0, 1.4.0 | moves to 1.4.0 | moves to 1.4.0, replacing rc.2 | +| 1.4.1-rc.1 | 1.3.0, 1.4.0 | stays at 1.4.0 | stays at 1.4.0 | + +The last row follows from the same rule. The test is whether the line has ever had a stable release, +not which version is higher, so once 1.4.0 has shipped the 1.4 line has a stable release in it and +no later 1.4 prerelease can take either tag. Publishing an rc out of order behaves the same way: + +| Publishing | Stable releases | `v1` | `v1.4` | +|---|---|---|---| +| 1.4.0-rc.1 | 1.5.0 | stays at 1.5.0 | created, at rc.1 | +| 1.4.0-rc.1 | 1.4.0, already shipped | stays at 1.4.0 | stays at 1.4.0 | + +Prereleases of one version are not ordered against each other, so republishing 1.4.0-rc.1 after +rc.2 moves `v1.4` back to rc.1. Ordering them would mean comparing prerelease identifiers by semver +precedence, which `sort -V` does not do. Stable releases are ordered, and are never moved backwards. + +The same rule gives a brand new major line to its own prerelease. Publishing 2.0.0-rc.1 with no +stable 2.x released resolves `v2` and `v2.0`, since nothing stable is behind either tag. + +A prerelease never blocks a released version either. A suffixed tag is not counted when working out +the newest version of a line, so an outstanding 1.9.0-rc.1 does not hold `v1` back from 1.3.1. + +## Where the versions come from + +The newest version of a line is read from the repository's git tags, through `repos.listTags` with +Octokit's `paginate`. Every tag is read, however many there are. + +A release listing is not used for it. Releases come back ordered by publication date and capped by +a page limit, so on a repo with more releases than the limit a high version published long ago sits +past the end. The comparison would then find nothing newer than the release being published and move +`v1` backwards onto it. Publishing 1.2.5 on a repo whose 1.5.0 is old enough to fall past the cap is +exactly the case that breaks, and it breaks silently. Tags have no such cap, and a draft release is +excluded for free, since a draft has no tag. + +The Releases API is needed for one thing: whether the release behind the `tag` input is marked as a +prerelease. That is one `repos.getReleaseByTag` call for that tag, not a listing. + +A tag whose name is a plain version therefore counts as a released version, even if the release +behind it is marked as a prerelease on GitHub. A suffixed tag never counts, whatever it is marked +as. This affects only which version is treated as the newest of a line, and it errs toward holding a +floating tag where it is. + +## A failed read stops the job + +If the tag list or a release's prerelease flag cannot be read, the step fails and no tag is written. + +An empty tag list cannot stand in for a failed one. A repository with no tags returns the same empty +list, and the comparison reads that as nothing being newer, so every requested tag would move. After +a failed read the tags move to whatever was published most recently: publishing a 1.2.5 backport +would move `v1` from 1.5.0 to 1.2.5. + +An unreadable prerelease flag cannot stand in for `false` either. A release GitHub marks as a +prerelease would take the tags with `allow-prereleases` off. + +Re-run the workflow to retry. No tag moved, so there is nothing to undo. + +A 404 from the release lookup is not a failure. A tag with no release behind it returns one, and it +counts as not a prerelease, so a repository that pushes tags without publishing Releases is +unaffected. + +## Building between the two steps + +A repo that commits build output at release time has a commit to make before it knows which commit +the tags should point at. The two actions are separate partly for this: this one reads only tag +names and the release, so it can run before the build, and move-git-tags takes the commit as an +input rather than using the one the workflow started on. + +```yaml +- name: Resolve the floating tags for the released version + id: floating + uses: stellarwp/plugin-toolbox/.github/actions/resolve-floating-tags@v1 + with: + tag: ${{ github.event.release.tag_name }} + +- name: Build, commit and push on top of the released commit + id: build + run: | + # ... build, commit, and push the commit to a branch ... + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + +- name: Point the release tag and the floating tags at the build + uses: stellarwp/plugin-toolbox/.github/actions/move-git-tags@v1 + with: + tags: ${{ github.event.release.tag_name }} ${{ steps.floating.outputs.tags }} + sha: ${{ steps.build.outputs.sha }} +``` + +The release tag is just another name to move-git-tags, so it can be moved in the same call. Moving +it through the API rather than by force-pushing is what keeps the Release attached to it. + +Which tags are resolved does not depend on where the build lands. Tag names are what this action +reads, and a build commit changes what a tag points at, not what it is called, so running it before +or after the build gives the same answer. + +The build commit has to be pushed before any tag can point at it. A ref can only name an object the +remote already has, so moving a tag to a commit that exists only on the runner fails. + +## How it runs + +The logic is in `resolve.js`, run by [actions/github-script](https://github.com/actions/github-script), +which supplies the Node runtime and an Octokit already authenticated with `token`. There is no +dependency to install and no bundle to build: the file in the repository is the file that runs, so a +change to it is reviewable as a diff. + +The script is loaded by path rather than inlined in the manifest, because github-script's `require` +resolves against the workspace of the repository being built, not this action's directory. The path +comes from `${{ github.action_path }}`. + +Inputs reach the script through `process.env`, never by being interpolated into it, so a tag name is +data and cannot become part of the program. + +`tests/actions/resolve-floating-tags/resolve.test.js` covers the rules above. It drives the +real Octokit with the network replaced, so the assertions are the requests this action would +send. Run `npm install` once, then `node --test`. diff --git a/.github/actions/resolve-floating-tags/action.yml b/.github/actions/resolve-floating-tags/action.yml new file mode 100644 index 0000000..6ebe09d --- /dev/null +++ b/.github/actions/resolve-floating-tags/action.yml @@ -0,0 +1,83 @@ +# Works out which floating tags a released version should own: v1 and v1.2 for 1.2.3. Writing them +# is a separate action: see .github/actions/move-git-tags. +# +# The decision lives in resolve.js. actions/github-script runs it with a pre-authenticated Octokit, +# so there is no dependency to install and nothing to build: the file in the repository is the file +# that runs. +name: Resolve floating version tags +description: Works out which v and v. tags a released version should own + +inputs: + tag: + description: >- + The release tag to resolve, with or without a leading `v`, e.g. `1.2.3` or `v1.2.3`. It needs + at least three dot-separated numbers. A fourth is read as part of the same line, so a hotfix + 1.2.3.1 counts as the newest release of 1.2; see the action's README. A prerelease suffix and + build metadata are read but are not part of a floating tag name. + required: true + levels: + description: >- + Which floating tags to resolve, space separated. `major` resolves v1, `minor` resolves v1.2. + Order does not matter, and naming one twice resolves one tag. Drop `minor` on a repo whose + consumers only ever pin a major. + required: false + default: 'major minor' + allow-prereleases: + description: >- + Let a prerelease own a floating tag whose line has had no stable release yet. Off by default, + so a release marked as a prerelease on GitHub, or tagged with a suffix such as `1.3.0-rc.1`, + resolves no tags at all. Turning it on never takes a tag from a stable release: a 1.4.0-rc.1 + resolves v1.4 and leaves v1 on the newest stable 1.x. See the action's README. + required: false + default: 'false' + repository: + description: 'The `/` whose tags and releases to read. Defaults to the current one.' + required: false + default: '' + token: + description: >- + Token used to read the repository's tags, and the prerelease flag of the release named by + `tag`. Read access to the repository's contents is enough. + required: false + default: ${{ github.token }} + +outputs: + tags: + description: >- + The floating tags this version should own, space separated, e.g. `v1 v1.2`, ordered from + shortest to longest. Pass it straight to move-git-tags. Empty when the version should own + none. + value: ${{ steps.resolve.outputs.tags }} + skipped: + description: >- + The floating tags a newer release of the same line already owns, space separated. Empty when + the version itself was rejected; read `version` to tell the two apart. + value: ${{ steps.resolve.outputs.skipped }} + version: + description: >- + The released version without its leading `v`, e.g. `1.2.3`. Empty when the tag was not a + numeric version of at least three parts. + value: ${{ steps.resolve.outputs.version }} + +runs: + using: 'composite' + steps: + - name: Resolve the floating tags for the released version + id: resolve + 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 a tag name is data and + # never part of the program. + INPUT_TAG: ${{ inputs.tag }} + INPUT_LEVELS: ${{ inputs.levels }} + INPUT_ALLOW_PRERELEASES: ${{ inputs.allow-prereleases }} + INPUT_REPOSITORY: ${{ inputs.repository }} + with: + github-token: ${{ inputs.token }} + script: | + const { run } = require(`${process.env.ACTION_PATH}/resolve.js`) + await run({ github, core, env: process.env }) diff --git a/.github/actions/resolve-floating-tags/resolve.js b/.github/actions/resolve-floating-tags/resolve.js new file mode 100644 index 0000000..018140e --- /dev/null +++ b/.github/actions/resolve-floating-tags/resolve.js @@ -0,0 +1,527 @@ +'use strict' + +/** + * Works out which floating version tags a released version should own. + * + * resolveFloatingTags is pure: it takes the repository's tag names as data and returns the + * decision, so every rule below can be tested without the API. run() is the thin wrapper that + * fetches what it needs and writes the outputs. + */ + +/** A version is numbers separated by dots, at least two of them. */ +const NUMERIC_VERSION = /^\d+(\.\d+)+$/ + +/** How many leading parts of the version each level's tag name carries. */ +const LEVEL_DEPTHS = { major: 1, minor: 2 } + +/** + * Turns a tag name into the version it stands for: drops a leading `v` and any build metadata. + * A prerelease suffix is left in place, so the result of a prerelease tag fails NUMERIC_VERSION + * and is excluded from every comparison. + * + * @param {string} tagName A tag name, e.g. `v1.2.3`, `1.4.0+build.7` or `1.4.0-rc.1`. + * + * @returns {string} The version it stands for, e.g. `1.2.3`, `1.4.0` or `1.4.0-rc.1`. + */ +function toVersion(tagName) { + return tagName.replace(/^v/, '').replace(/\+.*$/, '') +} + +/** + * Splits a version into its numbers, for comparing one part against another. + * + * @param {string} version A numeric version, e.g. `1.2.3`. + * + * @returns {number[]} Its parts, e.g. `[1, 2, 3]`. + */ +function toParts(version) { + return version.split('.').map(Number) +} + +/** + * Orders two versions by their numbers. 1.10.0 is above 1.9.0, and 1.2.3.1 above 1.2.3, neither of + * which a string comparison gets right. Missing trailing parts count as zero, so 1.2.3 and 1.2.3.0 + * are the same version. + * + * The semver package cannot express a version with four parts: 1.2.3.4 is invalid with or without + * its `loose` option, and comparing one throws. `coerce` drops the fourth part, so a 1.2.3.1 hotfix + * would compare equal to the 1.2.3 it patches and v1.2 would stop moving to a hotfix. A dependency + * would also have to be installed by every repository calling this action, since github-script + * resolves one from the calling repository's directory. + * + * semver does have prerelease precedence, ordering 1.4.0-rc.1 below 1.4.0, and this does not. No + * suffixed version reaches this function: releasedVersions keeps only numeric versions, and `core` + * has any suffix stripped. See Prereleases in the action's README. + * + * @param {string} a A numeric version. + * @param {string} b The version to compare it against. + * + * @returns {number} -1 when `a` is lower, 1 when it is higher, 0 when they are the same version. + */ +function compareVersions(a, b) { + const left = toParts(a) + const right = toParts(b) + + for (let i = 0; i < Math.max(left.length, right.length); i++) { + const difference = (left[i] ?? 0) - (right[i] ?? 0) + if (difference !== 0) { + return difference < 0 ? -1 : 1 + } + } + + return 0 +} + +/** + * Whether a version belongs to the line a floating tag covers. The line is named by its leading + * parts, and a member has to carry at least one part beyond them: 1.2.3 and 1.2.3.1 are both in the + * 1.2 line, 1.2 itself is not, and neither is 122.3.4. Comparing part by part is what keeps a + * longer number out of a shorter line. + * + * @param {string} version A numeric version, e.g. `1.2.3`. + * @param {number[]} linePrefix The numbers naming the line, e.g. `[1, 2]` for the v1.2 tag. + * + * @returns {boolean} Whether the version is a release of that line. + */ +function isInLine(version, linePrefix) { + const parts = toParts(version) + + return parts.length > linePrefix.length && linePrefix.every((part, i) => parts[i] === part) +} + +/** + * Reads a release tag into the three forms the rules need, and the numbers of the shortest. + * + * Each exists for a different job. For `v1.4.0-rc.1`, the shape a tag usually takes: + * + * - `version` is `1.4.0-rc.1`: the tag without its `v`. This is what the release is called, so it is + * the name the notices use and the version the action reports. + * - `releaseVersion` is `1.4.0-rc.1` as well, there being no build metadata to drop. This is how the + * tag reads once toVersion has normalised the repository's tag list, so it is the name that + * excludes the release from its own comparison. + * - `core` is `1.4.0`: the numbers alone. Versions are compared by this, and floating tag names are + * built from it. + * + * Comparing the two shorter forms is the prerelease test, because they differ by the prerelease + * suffix and nothing else. + * + * `releaseVersion` is a form of its own only to account for build metadata, which semver allows and + * says to ignore when ordering versions. Few tags carry any. One that did would otherwise read as a + * prerelease, its core differing from the whole tag, and would fail to exclude itself from its own + * comparison, since the tag list holds it with the metadata stripped. A tag carrying both is the + * only shape where all three forms differ: `v1.4.0-rc.1+build.7` reads as `1.4.0-rc.1+build.7`, + * `1.4.0-rc.1` and `1.4.0`. + * + * @param {string} tag The release tag, with or without a leading `v`. + * + * @returns {{version: string, releaseVersion: string, core: string, parts: number[], + * reason: string}} The three forms and the core's numbers, or a `reason` naming why the tag + * owns no floating tags at all. + */ +function readReleaseTag(tag) { + const version = String(tag).replace(/^v/, '') + const releaseVersion = version.replace(/\+.*$/, '') + const core = releaseVersion.replace(/-.*$/, '') + + if (!NUMERIC_VERSION.test(core)) { + return { reason: `${tag} is not a numeric version.` } + } + + const parts = toParts(core) + + /** + * Three parts is the floor so that the deepest floating tag, v., is always a + * shorter name than the release tag. A release tagged 1.2 would collide with v1.2. There is no + * upper limit: a fourth part is a hotfix, and isInLine reads it as another release of the same + * minor line rather than a line of its own. + */ + if (parts.length < 3) { + return { reason: `${tag} has ${parts.length} parts; at least three are needed.` } + } + + return { version, releaseVersion, core, parts } +} + +/** + * Turns the `levels` input into the lengths of the floating tags to resolve. + * + * Every name is checked before any tag is decided, so a typo fails the job rather than quietly + * writing a shorter set of tags than the caller asked for. + * + * @param {string} levels Space separated level names, e.g. `major minor`. + * + * @throws {Error} When a name is not a known level, or when none are named. + * + * @returns {number[]} How many leading version parts each requested tag carries, shortest first. + */ +function readLevels(levels) { + const depths = new Set() + + for (const level of String(levels).trim().split(/\s+/).filter(Boolean)) { + const depth = LEVEL_DEPTHS[level] + + if (!depth) { + throw new Error(`Unknown level '${level}' in levels. Use 'major', 'minor' or both.`) + } + + depths.add(depth) + } + + if (depths.size === 0) { + throw new Error('levels named no floating tags to resolve.') + } + + return [...depths].sort((a, b) => a - b) +} + +/** + * The released versions a floating tag can be held back by. + * + * A tag name carries no prerelease flag, so a tag that is a plain version counts as a released + * version even when GitHub marks its release as a prerelease; a suffixed tag never counts, because + * it fails NUMERIC_VERSION. + * + * The release being published is dropped by its own name rather than by its core version. A release + * marked as a prerelease while tagged a plain 1.3.0 would otherwise be the released version that + * blocks itself. Matching on the core instead would drop a released 1.4.0 when 1.4.0-rc.1 is + * published, and that 1.4.0 is exactly what has to block it. + * + * Fewer than three parts is dropped as well, because the floating tags are themselves tags in the + * repository and come back from the same listing. Without this, v1.0 reads as the version 1.0, + * which isInLine counts as a release of the 1.x line, and the action reads its own output back as + * proof that the line has shipped. A release always has three parts; a floating tag never does. + * + * @param {string[]} tagNames Every tag name in the repository. + * @param {string} excluding The name of the release being published, without build metadata. + * + * @returns {string[]} The versions to compare against. + */ +function releasedVersions(tagNames, excluding) { + return tagNames + .map(toVersion) + .filter( + (candidate) => + NUMERIC_VERSION.test(candidate) && + toParts(candidate).length >= 3 && + candidate !== excluding + ) +} + +/** + * A decision that a floating tag keeps pointing where it already does. + * + * @param {string} floating The tag's name. + * @param {string} message Why it is not moving, for the log. + * + * @returns {{floating: string, owned: boolean, notice: object}} The decision decideTag returns. + */ +function staysPut(floating, message) { + return { floating, owned: false, notice: { level: 'notice', message } } +} + +/** + * Decides whether one floating tag moves to the release being published. + * + * @param {number[]} linePrefix The numbers naming the line, e.g. `[1, 2]` for the v1.2 tag. + * @param {string[]} released The released versions to compare against. + * @param {object} release `{core, version, isPrerelease}` for the release being published. + * + * @returns {{floating: string, owned: boolean, notice: object}} The tag's name, whether this release + * owns it, and the notice explaining why when it does not. + */ +function decideTag(linePrefix, released, { core, version, isPrerelease }) { + const floating = `v${linePrefix.join('.')}` + const line = released.filter((candidate) => isInLine(candidate, linePrefix)) + + // Nothing released in this line: the first release of a new line, or a repository whose tags + // could not be read. Nothing can be newer, so the tag moves either way. + if (line.length === 0) { + return { floating, owned: true } + } + + const newest = line.reduce((a, b) => (compareVersions(a, b) >= 0 ? a : b)) + + /** + * A prerelease never takes a tag away from a released version. Anyone pinned to a line that has + * already had one expects a released version from it, so only a line that has never had one may + * point at a prerelease. A 1.4.0-rc.1 therefore takes v1.4, which has nothing released behind it, + * and leaves v1 on 1.3.0. + */ + if (isPrerelease) { + return staysPut(floating, `${floating} stays on ${newest}: ${version} is a prerelease.`) + } + + if (compareVersions(newest, core) > 0) { + return staysPut(floating, `${floating} stays where it is: ${newest} is newer than ${version}.`) + } + + return { floating, owned: true } +} + +/** + * The result for a release that owns no floating tags, carrying the reason to log. + * + * @param {string} reason Why it owns none. + * + * @returns {{tags: string[], skipped: string[], version: string, notices: object[]}} Empty lists + * and one notice. + */ +function resolvedNothing(reason) { + return { + tags: [], + skipped: [], + version: '', + notices: [{ level: 'notice', message: `Resolved no floating tags: ${reason}` }], + } +} + +/** + * Whether the release counts as a prerelease, by its own tag or by what GitHub says. + * + * @param {object} release A release read by readReleaseTag. + * @param {boolean} flaggedPrerelease Whether GitHub marks it as one. + * + * @returns {boolean} Whether it is a prerelease. + */ +function isPrereleaseRelease(release, flaggedPrerelease) { + return release.core !== release.releaseVersion || flaggedPrerelease +} + +/** + * The reason a prerelease owns no floating tag while allow-prereleases is off. + * + * Shared so that resolveFloatingTags and run report a skipped prerelease in the same words, even + * though they reach the decision at different points. + * + * @param {string} tag The release tag, as it was given. + * + * @returns {string} The reason, for resolvedNothing to log. + */ +function prereleaseSkipped(tag) { + return `${tag} is a prerelease. Set allow-prereleases to change that.` +} + +/** + * Decides each requested tag, once everything that can reject the release outright has passed. + * + * @param {object} release A release read by readReleaseTag. + * @param {number[]} depths Tag lengths read by readLevels. + * @param {boolean} prerelease Whether the release is a prerelease. + * @param {string[]} tagNames Every tag name in the repository. + * + * @returns {{tags: string[], skipped: string[], version: string, notices: object[]}} The same shape + * resolveFloatingTags returns. + */ +function decideTags({ release, depths, prerelease, tagNames }) { + const released = releasedVersions(tagNames, release.releaseVersion) + const notices = [] + const tags = [] + const skipped = [] + + for (const depth of depths) { + const linePrefix = release.parts.slice(0, depth) + const decision = decideTag(linePrefix, released, { ...release, isPrerelease: prerelease }) + + if (decision.owned) { + tags.push(decision.floating) + continue + } + + skipped.push(decision.floating) + notices.push(decision.notice) + } + + return { tags, skipped, version: release.version, notices } +} + +/** + * Decides the floating tags for one release. + * + * @param {string} tag The release tag, with or without a leading `v`. + * @param {boolean} flaggedPrerelease Whether GitHub marks that release as a prerelease. + * @param {string[]} tagNames Every tag name in the repository, to compare the release + * against. + * @param {string} levels Space separated level names, e.g. `major minor`. + * @param {boolean} allowPrereleases Let a prerelease own a tag whose line has had no release. + * + * @throws {Error} When levels names something other than a known level. + * + * @returns {{tags: string[], skipped: string[], version: string, notices: object[]}} The tags this + * version should own, the ones a newer release already owns, the version itself, and the + * messages the caller should log. + */ +function resolveFloatingTags({ + tag, + flaggedPrerelease = false, + tagNames = [], + levels = 'major minor', + allowPrereleases = false, +}) { + const release = readReleaseTag(tag) + + if (release.reason) { + return resolvedNothing(release.reason) + } + + const depths = readLevels(levels) + const prerelease = isPrereleaseRelease(release, flaggedPrerelease) + + if (prerelease && !allowPrereleases) { + return resolvedNothing(prereleaseSkipped(tag)) + } + + return decideTags({ release, depths, prerelease, tagNames }) +} + +/** + * Every tag name in the repository, over as many pages as it takes. + * + * A failed read throws. An empty list cannot stand in for one, because a repository with no tags + * returns the same empty list, and the comparison reads that as nothing being newer: every requested + * tag would move to whatever was published most recently, so a 1.2.5 backport would take v1 off + * 1.5.0. Throwing leaves every tag where it is, and the workflow can be re-run. + * + * @param {object} github An Octokit, as actions/github-script supplies it. + * @param {string} owner The repository owner. + * @param {string} repo The repository name. + * + * @throws {Error} When the tags cannot be read. + * + * @returns {Promise} The tag names. Empty only when the repository has none. + */ +async function readTagNames({ github, owner, repo }) { + try { + const tags = await github.paginate(github.rest.repos.listTags, { owner, repo, per_page: 100 }) + + return tags.map((tag) => tag.name) + } catch (error) { + throw new Error( + `Could not read ${owner}/${repo}'s tags, so no floating tag can be moved safely: ` + + error.message + ) + } +} + +/** + * Whether GitHub marks the release behind a tag as a prerelease. + * + * A tag with no release behind it returns 404, which counts as not a prerelease, so a repository + * that pushes tags without publishing Releases still resolves. + * + * Any other failure throws. Reading it as not a prerelease would let a release GitHub marks as one + * take the floating tags with allow-prereleases off. + * + * @param {object} github An Octokit, as actions/github-script supplies it. + * @param {string} owner The repository owner. + * @param {string} repo The repository name. + * @param {string} tag The tag to look the release up by. + * + * @throws {Error} When the release cannot be read for any reason but a 404. + * + * @returns {Promise} Whether that release is marked as a prerelease. + */ +async function readPrereleaseFlag({ github, owner, repo, tag }) { + try { + const release = await github.rest.repos.getReleaseByTag({ owner, repo, tag }) + + return release.data.prerelease + } catch (error) { + if (error.status !== 404) { + throw new Error( + `Could not tell whether ${tag} is a prerelease, so no floating tag can be moved safely: ` + + error.message + ) + } + + return false + } +} + +/** + * Logs what was decided and writes the action's outputs. + * + * @param {object} core The @actions/core toolkit. + * @param {string} tag The tag that was resolved, for the log line when it was rejected. + * @param {object} resolved A result from resolveFloatingTags or decideTags. + * + * @returns {void} Nothing. The outputs are the result. + */ +function report({ core, tag, resolved }) { + for (const notice of resolved.notices) { + core[notice.level](notice.message) + } + + core.info(`Resolved for ${resolved.version || tag}: ${resolved.tags.join(' ') || 'none'}`) + + core.setOutput('tags', resolved.tags.join(' ')) + core.setOutput('skipped', resolved.skipped.join(' ')) + core.setOutput('version', resolved.version) +} + +/** + * Reads what the repository has, resolves the floating tags and writes the action's outputs. + * + * @param {object} github An Octokit, as actions/github-script supplies it. + * @param {object} core The @actions/core toolkit, for the log and the outputs. + * @param {object} env The environment the action manifest put its inputs in. + * + * @returns {Promise} Resolves once the outputs are set. + */ +async function run({ github, core, env }) { + const repository = env.INPUT_REPOSITORY || env.GITHUB_REPOSITORY + const [owner, repo] = repository.split('/') + const tag = env.INPUT_TAG + + /** + * The steps run cheapest first, so nothing is fetched until it could still change the answer. + * A tag that is not a version, and a level that is not a level, are settled without a request. + * A prerelease nobody asked for costs the single release lookup. Only a release that can still + * own a tag pays for the tag list, which is the paginated one. + * + * resolveFloatingTags runs these same steps in this same order, for a caller that already has + * the tags. The tests assert the requests this makes, so the two cannot drift apart unnoticed. + */ + const release = readReleaseTag(tag) + + if (release.reason) { + return report({ core, tag, resolved: resolvedNothing(release.reason) }) + } + + const depths = readLevels(env.INPUT_LEVELS) + + // A `-` suffix settles this on its own, so the release is only looked up for a plain version, + // where nothing but GitHub's flag can say. + const prerelease = + release.core !== release.releaseVersion || + (await readPrereleaseFlag({ github, owner, repo, tag })) + + if (prerelease && env.INPUT_ALLOW_PRERELEASES !== 'true') { + return report({ core, tag, resolved: resolvedNothing(prereleaseSkipped(tag)) }) + } + + const tagNames = await readTagNames({ github, owner, repo }) + + return report({ core, tag, resolved: decideTags({ release, depths, prerelease, tagNames }) }) +} + +module.exports = { + run, + report, + readTagNames, + readPrereleaseFlag, + resolveFloatingTags, + resolvedNothing, + isPrereleaseRelease, + prereleaseSkipped, + decideTags, + readReleaseTag, + readLevels, + releasedVersions, + decideTag, + staysPut, + compareVersions, + isInLine, + toVersion, + toParts, + NUMERIC_VERSION, +} diff --git a/.github/workflows/release-tags.yml b/.github/workflows/release-tags.yml new file mode 100644 index 0000000..169c1bf --- /dev/null +++ b/.github/workflows/release-tags.yml @@ -0,0 +1,62 @@ +name: Move floating version tags + +# This repo's own release automation, and the example other repos copy. +# +# Consuming repos pin a floating tag ("uses: stellarwp/plugin-toolbox/.github/actions/setup-slic@v1"), +# so every published release has to move v and v. onto the released commit. +# +# The work is split into two actions so that either half can be used on its own: +# resolve-floating-tags works out which tags the version should own, move-git-tags writes them. To +# run this in another repo, copy this file and replace the local `uses:` paths with +# `stellarwp/plugin-toolbox/.github/actions/@v1`. +# +# Run it locally: you cannot, it only makes sense against real refs. Check a release afterwards +# with `gh api repos/stellarwp/plugin-toolbox/git/ref/tags/v1 --jq .object.sha`. + +on: + release: + types: [published] + +# Two releases published close together would otherwise read the tag list and write to it at the +# same time, and the later write would win regardless of which release is newer. One group for the +# whole workflow serialises them. +# +# `queue: max` because the default, `single`, keeps only one run pending and cancels the one already +# waiting. Publishing three releases at once would move the tags for the first and the last, and +# silently drop the middle. +concurrency: + group: ${{ github.workflow }} + queue: max + +permissions: + contents: read + +jobs: + move-floating-tags: + runs-on: ubuntu-latest + permissions: + contents: write # move-git-tags writes the tag refs + steps: + # Only so that the two `uses: ./` paths below resolve. Neither action reads the working tree, + # so a repo calling them by their `@v1` path needs no checkout at all. + # + # No ref on purpose: the actions run at the released commit, not at the version on the default + # branch. A release event reads this workflow from the released commit too, so the workflow and + # the actions it calls are always the same revision. + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + # Nothing here runs git. Both actions below write through the API with the token they are + # handed, so keeping credentials in .git/config would only leave them for a later step. + persist-credentials: false + + - name: Resolve the floating tags for this release + id: floating + uses: ./.github/actions/resolve-floating-tags + with: + tag: ${{ github.event.release.tag_name }} + + - name: Move them onto the released commit + uses: ./.github/actions/move-git-tags + with: + tags: ${{ steps.floating.outputs.tags }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..0450a2e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,42 @@ +name: Tests + +# This repo's own checks. A bug here reaches every repo that pins a tag from it, so the logic in the +# actions is unit tested rather than only exercised by a real release. +# +# Run it locally: `npm install` once, then `node --test`. + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + # Node 24 is what actions/github-script@v9 runs the action scripts on, so the tests run on the + # same one. + - uses: actions/setup-node@v6 + with: + node-version: '24' + + # `npm install`, not `npm ci`: ci exists to install from a committed lockfile, and no lockfile + # is committed here. Each run resolves the newest versions the ranges in package.json allow, + # which is the point. The tests drive the real @actions/github and @actions/core, and pinning + # them would let the versions under test drift from the ones a release actually runs against + # without anything saying so. + - name: Install the test dependencies + run: npm install + + # No path argument: `node --test ` is read as a module to load rather than a directory + # to search on some Node versions, including the 24.x the runner ships. Bare `node --test` + # finds every *.test.js under the repository on all of them. + - name: Run the unit tests + run: node --test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da63dc1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ + +# No lockfile is committed. Each install resolves the newest versions the ranges in package.json +# allow, so the versions the tests run against cannot quietly drift from the ones a release gets. +package-lock.json +npm-shrinkwrap.json +bun.lock +bun.lockb +yarn.lock diff --git a/README.md b/README.md index ad0d980..972aa15 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,7 @@ Each phase ends with something people can actually use. - [ ] `changelog.yml`: the changelogger check. - [ ] `zip.yml`: reconcile TEC's and LD's versions. - [ ] `release-prep.yml`: version bump, changelog, tested-up-to. +- [ ] `release-tags.yml`: a `workflow_call` wrapper for the floating tag actions, so a repo gets a `uses:` instead of copying the workflow. The actions have to be referenced by full path, not `./`, because a relative path in a called workflow resolves against the caller. - [ ] Document each in `docs/continuous-integration.md` with its local command. ### Phase 4: file sync diff --git a/package.json b/package.json new file mode 100644 index 0000000..7527e5b --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "@stellarwp/plugin-toolbox", + "private": true, + "description": "Shared CI for StellarWP plugin repositories", + "license": "GPL-2.0-or-later", + "scripts": { + "test": "node --test" + }, + "devDependencies": { + "@actions/core": "^3.0.1", + "@actions/github": "^9.1.1" + } +} diff --git a/tests/actions/move-git-tags/move.test.js b/tests/actions/move-git-tags/move.test.js new file mode 100644 index 0000000..92f4560 --- /dev/null +++ b/tests/actions/move-git-tags/move.test.js @@ -0,0 +1,273 @@ +'use strict' + +const { describe, it } = require('node:test') +const assert = require('node:assert') + +const { moveTags, parseTagNames, run } = require('../../../.github/actions/move-git-tags/move.js') +const { actionsFor } = require('../../support/actions.js') + +const REPO = { owner: 'stellarwp', repo: 'plugin-toolbox' } + +/** The repository paths this action writes, as a real Octokit spells them. */ +const REF = { + read: (tag) => `/repos/stellarwp/plugin-toolbox/git/ref/tags%2F${tag}`, + update: (tag) => `/repos/stellarwp/plugin-toolbox/git/refs/tags%2F${tag}`, + create: '/repos/stellarwp/plugin-toolbox/git/refs', +} + +/** + * Rules that answer the existence check for `existing` and accept every write. + * + * A tag not in `existing` falls through to the default 404, which is what the action reads as "not + * there yet". + * + * @param {string[]} existing Tag names that already have a ref. + * @param {string} base Repository path the requests go to. + * + * @returns {object[]} Rules for recordingFetch. + */ +function rulesFor(existing = [], base = '/repos/stellarwp/plugin-toolbox') { + return [ + ...existing.map((tag) => ({ + method: 'GET', + path: `${base}/git/ref/tags%2F${tag}`, + body: { ref: `refs/tags/${tag}` }, + })), + { method: 'PATCH', path: /\/git\/refs\/tags%2F/, body: {} }, + { method: 'POST', path: `${base}/git/refs`, body: {} }, + ] +} + +/** + * Each request as `METHOD path`, for asserting the order the action worked in. + * + * @param {object[]} requests The requests recordingFetch recorded. + * + * @returns {string[]} One `METHOD path` per request, in the order they were sent. + */ +function sequence(requests) { + return requests.map((request) => `${request.method} ${request.path}`) +} + +describe('move-git-tags', () => { + describe('parseTagNames', () => { + it('splits on spaces and on newlines', () => { + assert.deepEqual(parseTagNames('v1 v1.2'), ['v1', 'v1.2']) + assert.deepEqual(parseTagNames('v1\nv1.2\n'), ['v1', 'v1.2']) + }) + + it('reads an empty or missing value as no tags', () => { + assert.deepEqual(parseTagNames(''), []) + assert.deepEqual(parseTagNames(' '), []) + assert.deepEqual(parseTagNames(undefined), []) + }) + + it('refuses a full ref, naming the tag to pass instead', () => { + assert.throws(() => parseTagNames('refs/tags/v1'), /pass the tag name, e\.g\. 'v1'/) + }) + + it('refuses a name that could reshape a request', () => { + assert.throws(() => parseTagNames('../../evil'), /not a plain tag name/) + assert.throws(() => parseTagNames('v1..2'), /not a plain tag name/) + assert.throws(() => parseTagNames('v1;rm'), /not a plain tag name/) + }) + + it('refuses a name starting with a dash or a dot', () => { + assert.throws(() => parseTagNames('-v1'), /not a plain tag name/) + assert.throws(() => parseTagNames('.hidden'), /not a plain tag name/) + }) + }) + + describe('moveTags', () => { + it('reads each ref from the single-ref endpoint, not the prefix one', async (t) => { + // git/ref answers 404 for a missing ref. git/refs matches by prefix, so it would answer for + // tags/v1 whenever a v1.0.0 tag exists, and no tag would ever look missing. + const { github, requests, core } = await actionsFor(t, rulesFor(['v1'])) + + await moveTags({ github, core, ...REPO, names: ['v1'], sha: 'abc123' }) + + assert.equal(requests[0].method, 'GET') + assert.equal(requests[0].path, '/repos/stellarwp/plugin-toolbox/git/ref/tags%2Fv1') + }) + + it('repoints an existing tag with a forced update, and creates a missing one', async (t) => { + const { github, requests, core } = await actionsFor(t, rulesFor(['v1'])) + + const result = await moveTags({ + github, + core, + ...REPO, + names: ['v1', 'v1.2'], + sha: 'abc123', + }) + + assert.deepEqual(result, { created: ['v1.2'], moved: ['v1'] }) + assert.deepEqual(sequence(requests), [ + `GET ${REF.read('v1')}`, + `PATCH ${REF.update('v1')}`, + `GET ${REF.read('v1.2')}`, + `POST ${REF.create}`, + ]) + }) + + it('sends the sha and force on an update, and the full ref on a creation', async (t) => { + const { github, requests, core } = await actionsFor(t, rulesFor(['v1'])) + + await moveTags({ + github, + core, + ...REPO, + names: ['v1', 'v1.2'], + sha: 'abc123', + }) + + const [, update, , create] = requests + + assert.deepEqual(update.body, { sha: 'abc123', force: true }) + assert.deepEqual(create.body, { ref: 'refs/tags/v1.2', sha: 'abc123' }) + }) + + it('writes to the repository it was given', async (t) => { + const base = '/repos/another-owner/another-repo' + const { github, requests, core } = await actionsFor(t, rulesFor([], base)) + + await moveTags({ + github, + core, + owner: 'another-owner', + repo: 'another-repo', + names: ['v9'], + sha: 'abc123', + }) + + assert.ok( + requests.every((request) => request.path.startsWith('/repos/another-owner/another-repo/')), + `every request went to the given repository, got ${JSON.stringify(sequence(requests))}` + ) + }) + + it('fails on a refused update rather than retrying it as a creation', async (t) => { + // A 403 from a protected tag must not be read as "does not exist yet", which is what an + // update-then-create-on-failure fallback would do. + const { github, requests, core } = await actionsFor(t, [ + { method: 'GET', path: REF.read('v1'), body: { ref: 'refs/tags/v1' } }, + { method: 'PATCH', path: /\/git\/refs\/tags%2F/, status: 403, body: { message: 'Forbidden' } }, + ]) + + await assert.rejects( + moveTags({ github, core, ...REPO, names: ['v1'], sha: 'abc123' }), + /Forbidden/ + ) + + assert.ok(!sequence(requests).some((call) => call.startsWith('POST')), 'nothing was created') + }) + + it('does not treat a failure other than 404 as a missing tag', async (t) => { + const { github, requests, core } = await actionsFor(t, [ + { method: 'GET', path: /\/git\/ref\//, status: 401, body: { message: 'Bad credentials' } }, + ]) + + await assert.rejects( + moveTags({ github, core, ...REPO, names: ['v1'], sha: 'abc123' }), + /Bad credentials/ + ) + + assert.equal(requests.length, 1, 'it stopped at the failed read') + }) + }) + + describe('run', () => { + it('succeeds on an empty tag list without sending a request', async (t) => { + const { github, requests, core, outputs } = await actionsFor(t) + + await run({ + github, + core, + env: { INPUT_TAGS: '', GITHUB_REPOSITORY: 'stellarwp/plugin-toolbox', GITHUB_SHA: 'abc123' }, + }) + + assert.deepEqual(requests, []) + assert.deepEqual(outputs(), { created: '', moved: '' }) + }) + + it('defaults the sha and repository to the workflow it is running in', async (t) => { + const { github, requests, core, outputs } = await actionsFor(t, rulesFor([])) + + await run({ + github, + core, + env: { + INPUT_TAGS: 'v2', + GITHUB_REPOSITORY: 'stellarwp/plugin-toolbox', + GITHUB_SHA: 'feed456', + }, + }) + + assert.deepEqual(sequence(requests), [`GET ${REF.read('v2')}`, `POST ${REF.create}`]) + assert.equal(requests[1].body.sha, 'feed456') + assert.deepEqual(outputs(), { created: 'v2', moved: '' }) + }) + + it('prefers the sha and repository it is given', async (t) => { + const rules = rulesFor(['v2'], '/repos/stellarwp/other') + const { github, requests, core, outputs } = await actionsFor(t, rules) + + await run({ + github, + core, + env: { + INPUT_TAGS: 'v2', + INPUT_SHA: 'chosen1', + INPUT_REPOSITORY: 'stellarwp/other', + GITHUB_REPOSITORY: 'stellarwp/plugin-toolbox', + GITHUB_SHA: 'feed456', + }, + }) + + assert.deepEqual(sequence(requests), [ + '/repos/stellarwp/other/git/ref/tags%2Fv2', + '/repos/stellarwp/other/git/refs/tags%2Fv2', + ].map((path, i) => `${['GET', 'PATCH'][i]} ${path}`)) + assert.equal(requests[1].body.sha, 'chosen1') + assert.deepEqual(outputs(), { created: '', moved: 'v2' }) + }) + + it('moves a release tag alongside the floating tags, to a commit made later', async (t) => { + /** + * The pattern a repo with a build step uses: resolve the floating tags from the released tag, + * build and commit, then point the release tag and the floating tags at the build commit. The + * release tag is just another name to this action, and the sha is an input rather than the + * commit the workflow started on. + */ + const { github, requests, core, outputs } = await actionsFor(t, rulesFor(['v1.2.3', 'v1'])) + + await run({ + github, + core, + env: { + INPUT_TAGS: 'v1.2.3 v1 v1.2', + INPUT_SHA: 'bui1dc0mmit', + GITHUB_REPOSITORY: 'stellarwp/plugin-toolbox', + GITHUB_SHA: 'thereleasedcommit', + }, + }) + + assert.deepEqual(sequence(requests), [ + `GET ${REF.read('v1.2.3')}`, + `PATCH ${REF.update('v1.2.3')}`, + `GET ${REF.read('v1')}`, + `PATCH ${REF.update('v1')}`, + `GET ${REF.read('v1.2')}`, + `POST ${REF.create}`, + ]) + const writes = requests.filter((request) => request.body) + + assert.ok( + writes.every((request) => request.body.sha === 'bui1dc0mmit'), + 'every write used the build commit, not the released one' + ) + assert.deepEqual(outputs(), { created: 'v1.2', moved: 'v1.2.3 v1' }) + }) + }) +}) + diff --git a/tests/actions/resolve-floating-tags/resolve.test.js b/tests/actions/resolve-floating-tags/resolve.test.js new file mode 100644 index 0000000..378ab69 --- /dev/null +++ b/tests/actions/resolve-floating-tags/resolve.test.js @@ -0,0 +1,1069 @@ +'use strict' + +const { describe, it } = require('node:test') +const assert = require('node:assert') + +const { + run, + report, + readTagNames, + readPrereleaseFlag, + resolveFloatingTags, + resolvedNothing, + isPrereleaseRelease, + prereleaseSkipped, + decideTags, + readReleaseTag, + readLevels, + releasedVersions, + decideTag, + staysPut, + compareVersions, + isInLine, + toVersion, + toParts, +} = require('../../../.github/actions/resolve-floating-tags/resolve.js') +const { actionsFor } = require('../../support/actions.js') + +const REPO = { owner: 'stellarwp', repo: 'plugin-toolbox' } +const TAGS_PATH = /\/repos\/stellarwp\/plugin-toolbox\/tags(\?|$)/ +const RELEASE_PATH = (tag) => `/repos/stellarwp/plugin-toolbox/releases/tags/${tag}` + +/** + * A page of the tag listing, shaped the way repos.listTags returns it. + * + * @param {...string} names The tag names on this page. + * + * @returns {object[]} One listing entry per name. + */ +function tagPage(...names) { + return names.map((name) => ({ name, commit: { sha: 'abc123' } })) +} + +/** + * Rules for a repository whose tags come back in a single page. + * + * @param {string[]} names Every tag name the repository has. + * @param {object} release `{tag, prerelease}` for the release behind that tag. Left out, the + * release lookup answers 404, as it does for a tag with no release. + * + * @returns {object[]} Rules for recordingFetch. + */ +function repoWith(names, release) { + return [ + release + ? { method: 'GET', path: RELEASE_PATH(release.tag), body: { prerelease: release.prerelease } } + : { method: 'GET', path: /\/releases\/tags\//, status: 404, body: { message: 'Not Found' } }, + { method: 'GET', path: TAGS_PATH, body: tagPage(...names) }, + ] +} + +describe('resolve-floating-tags', () => { + describe('compareVersions', () => { + it('puts 1.10.0 above 1.9.0, which a string comparison does not', () => { + assert.equal(compareVersions('1.10.0', '1.9.0'), 1) + assert.equal(compareVersions('1.2.10.0', '1.2.9.9'), 1) + }) + + it('puts a hotfix above the release it patches', () => { + assert.equal(compareVersions('1.2.3.1', '1.2.3'), 1) + }) + + it('counts a missing trailing part as zero', () => { + assert.equal(compareVersions('1.2.3', '1.2.3.0'), 0) + }) + + it('orders a lower minor below a higher one', () => { + assert.equal(compareVersions('1.2.3', '1.3.0'), -1) + }) + }) + + describe('isInLine', () => { + it('includes a release of the line', () => { + assert.equal(isInLine('1.2.3', [1]), true) + assert.equal(isInLine('1.2.3', [1, 2]), true) + }) + + it('includes a hotfix in its minor line', () => { + assert.equal(isInLine('1.2.3.1', [1, 2]), true) + }) + + it('excludes a longer number that only looks like a prefix', () => { + assert.equal(isInLine('122.3.4', [1, 2]), false) + assert.equal(isInLine('1.22.3', [1, 2]), false) + assert.equal(isInLine('10.0.0', [1]), false) + }) + + it('excludes the name of the line itself', () => { + assert.equal(isInLine('1.2', [1, 2]), false) + }) + }) + + describe('toVersion', () => { + it('drops a leading v', () => { + assert.equal(toVersion('v1.2.3'), '1.2.3') + }) + + it('drops build metadata', () => { + assert.equal(toVersion('1.4.0+build.7'), '1.4.0') + }) + + it('keeps a prerelease suffix, so the version fails the numeric test', () => { + assert.equal(toVersion('1.4.0-rc.1'), '1.4.0-rc.1') + }) + }) + + describe('toParts', () => { + it('reads each part as a number', () => { + assert.deepEqual(toParts('1.2.3'), [1, 2, 3]) + assert.deepEqual(toParts('1.2.3.4'), [1, 2, 3, 4]) + }) + }) + + describe('readReleaseTag', () => { + it('reads the three forms of a plain version', () => { + assert.deepEqual(readReleaseTag('1.2.3'), { + version: '1.2.3', + releaseVersion: '1.2.3', + core: '1.2.3', + parts: [1, 2, 3], + }) + }) + + it('drops a leading v from all three', () => { + const release = readReleaseTag('v1.2.3') + + assert.equal(release.version, '1.2.3') + assert.equal(release.core, '1.2.3') + }) + + it('keeps build metadata out of the core but inside the version', () => { + const release = readReleaseTag('1.4.0+build.7') + + assert.equal(release.version, '1.4.0+build.7') + assert.equal(release.releaseVersion, '1.4.0', 'metadata is not part of the release name') + assert.equal(release.core, '1.4.0') + }) + + it('keeps a prerelease suffix in the release name but out of the core', () => { + const release = readReleaseTag('1.4.0-rc.1') + + assert.equal(release.releaseVersion, '1.4.0-rc.1', 'the suffix is what marks a prerelease') + assert.equal(release.core, '1.4.0') + }) + + it('tells the three forms apart on a tag carrying both', () => { + const release = readReleaseTag('v1.4.0-rc.1+build.7') + + assert.equal(release.version, '1.4.0-rc.1+build.7', 'what the release is called') + assert.equal(release.releaseVersion, '1.4.0-rc.1', 'how it reads in the tag list') + assert.equal(release.core, '1.4.0', 'what versions are compared by') + }) + + it('reads a fourth part as part of the version', () => { + assert.deepEqual(readReleaseTag('1.2.3.1').parts, [1, 2, 3, 1]) + }) + + it('gives a reason instead of forms when the tag is not a numeric version', () => { + for (const tag of ['nonsense', 'v1', 'latest', 'v.1.2', '1']) { + assert.equal(readReleaseTag(tag).reason, `${tag} is not a numeric version.`, tag) + } + }) + + it('reads a trailing dash as a prerelease with nothing after it', () => { + // Not a version anyone writes, but the dash is what marks a prerelease, so 1.2.3- is read as + // one rather than rejected. It resolves nothing unless allow-prereleases is on, like any + // other. + const release = readReleaseTag('1.2.3-') + + assert.equal(release.reason, undefined) + assert.equal(release.core, '1.2.3') + assert.equal(release.releaseVersion, '1.2.3-') + }) + + it('gives a reason naming the count when there are fewer than three parts', () => { + assert.equal(readReleaseTag('1.2').reason, '1.2 has 2 parts; at least three are needed.') + }) + }) + + describe('readLevels', () => { + it('reads a level as the number of version parts its tag carries', () => { + assert.deepEqual(readLevels('major'), [1]) + assert.deepEqual(readLevels('minor'), [2]) + }) + + it('returns them shortest first, whatever order they were written in', () => { + assert.deepEqual(readLevels('minor major'), [1, 2]) + }) + + it('reads a level named twice as one tag', () => { + assert.deepEqual(readLevels('major minor major'), [1, 2]) + }) + + it('ignores surrounding and repeated whitespace', () => { + assert.deepEqual(readLevels(' major minor '), [1, 2]) + }) + + it('throws on a name that is not a level, naming it', () => { + assert.throws(() => readLevels('major hotfix'), /Unknown level 'hotfix'/) + }) + + it('throws when no level is named', () => { + assert.throws(() => readLevels(' '), /named no floating tags/) + assert.throws(() => readLevels(''), /named no floating tags/) + }) + }) + + describe('releasedVersions', () => { + it('reads each tag as the version it stands for', () => { + assert.deepEqual(releasedVersions(['v1.2.3', '1.4.0+build.7'], ''), ['1.2.3', '1.4.0']) + }) + + it('leaves out a tag that is not a version', () => { + assert.deepEqual(releasedVersions(['latest', 'v1', '1.2.3'], ''), ['1.2.3']) + }) + + it('leaves out a prerelease, which carries no released version', () => { + assert.deepEqual(releasedVersions(['1.4.0-rc.1', '1.3.0'], ''), ['1.3.0']) + }) + + it('leaves out the release being published, so it cannot block itself', () => { + assert.deepEqual(releasedVersions(['1.3.0', '1.2.1'], '1.3.0'), ['1.2.1']) + }) + + it('ignores the floating tags, which are tags in the repository too', () => { + // listTags returns v1 and v1.0 alongside the releases. Read as versions they would be 1 and + // 1.0, and isInLine counts 1.0 as a release of the 1.x line, so the action would read its own + // output back as proof that the line has shipped. A release always has three parts. + assert.deepEqual(releasedVersions(['v1', 'v1.0', 'v1.2', '1.2.3'], ''), ['1.2.3']) + }) + + it('keeps a released version that shares a published prerelease core', () => { + // 1.4.0-rc.1 is published while 1.4.0 is already out. Excluding by core instead of by name + // would drop the 1.4.0 that has to block the rc. + assert.deepEqual(releasedVersions(['1.4.0', '1.3.0'], '1.4.0-rc.1'), ['1.4.0', '1.3.0']) + }) + }) + + describe('staysPut', () => { + it('carries the tag, that it is not owned, and the reason to log', () => { + assert.deepEqual(staysPut('v1', 'v1 stays where it is: 1.3.0 is newer than 1.2.5.'), { + floating: 'v1', + owned: false, + notice: { level: 'notice', message: 'v1 stays where it is: 1.3.0 is newer than 1.2.5.' }, + }) + }) + }) + + describe('decideTag', () => { + const release = { core: '1.2.5', version: '1.2.5', isPrerelease: false } + + it('names the tag after the numbers of its line', () => { + assert.equal(decideTag([1], [], release).floating, 'v1') + assert.equal(decideTag([1, 2], [], release).floating, 'v1.2') + }) + + it('moves the tag when its line has nothing released in it', () => { + assert.deepEqual(decideTag([1, 2], ['2.0.0'], release), { floating: 'v1.2', owned: true }) + }) + + it('moves the tag when this release is the newest of its line', () => { + assert.deepEqual(decideTag([1, 2], ['1.2.4'], release), { floating: 'v1.2', owned: true }) + }) + + it('moves the tag when the newest release is the same version written longer', () => { + /** + * compareVersions reads 1.2.3 and 1.2.3.0 as one version, so neither holds the other back. + * Only a strictly newer release does, which a repo that writes a fourth part for its base + * releases depends on. + */ + assert.deepEqual(decideTag([1, 2], ['1.2.3.0'], { + core: '1.2.3', + version: '1.2.3', + isPrerelease: false, + }), { floating: 'v1.2', owned: true }) + }) + + it('leaves the tag when a newer release of the line exists, saying which', () => { + const decision = decideTag([1], ['1.3.0'], release) + + assert.equal(decision.owned, false) + assert.equal(decision.notice.message, 'v1 stays where it is: 1.3.0 is newer than 1.2.5.') + }) + + it('leaves the tag to a released version when this release is a prerelease', () => { + const rc = { core: '1.4.0', version: '1.4.0-rc.1', isPrerelease: true } + const decision = decideTag([1], ['1.3.0'], rc) + + assert.equal(decision.owned, false) + assert.equal(decision.notice.message, 'v1 stays on 1.3.0: 1.4.0-rc.1 is a prerelease.') + }) + + it('moves the tag for a prerelease when its line has nothing released', () => { + const rc = { core: '1.4.0', version: '1.4.0-rc.1', isPrerelease: true } + + assert.deepEqual(decideTag([1, 4], ['1.3.0'], rc), { floating: 'v1.4', owned: true }) + }) + }) + + describe('readTagNames', () => { + it('returns every tag name, over as many pages as it takes', async (t) => { + const { github, core, requests } = await actionsFor(t, [ + { + method: 'GET', + path: TAGS_PATH, + responses: [ + { + body: tagPage('1.2.0'), + headers: { + link: '; rel="next"', + }, + }, + { body: tagPage('1.9.0') }, + ], + }, + ]) + + const names = await readTagNames({ github, core, ...REPO }) + + assert.deepEqual(names, ['1.2.0', '1.9.0']) + assert.equal(requests.length, 2, 'it followed the Link header') + }) + + it('throws when the tags cannot be read, rather than answering with none', async (t) => { + /** + * No tags is also what a repository with none answers, and the comparison reads that as + * nothing being newer, so every requested tag would move. After a failed read that takes v1 + * off the newest release and puts it on whatever was published last. + */ + const { github } = await actionsFor(t, [ + { method: 'GET', path: TAGS_PATH, status: 403, body: { message: 'Forbidden' } }, + ]) + + await assert.rejects( + readTagNames({ github, ...REPO }), + /Could not read stellarwp\/plugin-toolbox's tags, so no floating tag can be moved safely/ + ) + }) + + it('returns no tags for a repository that has none', async (t) => { + const { github } = await actionsFor(t, [{ method: 'GET', path: TAGS_PATH, body: [] }]) + + assert.deepEqual(await readTagNames({ github, ...REPO }), []) + }) + }) + + describe('readPrereleaseFlag', () => { + it('reports a release marked as a prerelease', async (t) => { + const { github, core } = await actionsFor(t, [ + { method: 'GET', path: RELEASE_PATH('v1.3.0'), body: { prerelease: true } }, + ]) + + assert.equal(await readPrereleaseFlag({ github, core, ...REPO, tag: 'v1.3.0' }), true) + }) + + it('reports a release that is not marked as one', async (t) => { + const { github, core } = await actionsFor(t, [ + { method: 'GET', path: RELEASE_PATH('v1.3.0'), body: { prerelease: false } }, + ]) + + assert.equal(await readPrereleaseFlag({ github, core, ...REPO, tag: 'v1.3.0' }), false) + }) + + it('reports a tag with no release behind it as not a prerelease, without warning', async (t) => { + const { github, core, logged } = await actionsFor(t, [ + { method: 'GET', path: RELEASE_PATH('v1.3.0'), status: 404, body: { message: 'Not Found' } }, + ]) + + assert.equal(await readPrereleaseFlag({ github, core, ...REPO, tag: 'v1.3.0' }), false) + assert.equal(logged(), '', 'a repo that pushes tags without Releases is not a problem') + }) + + it('throws when the lookup fails for any other reason', async (t) => { + // Reading it as not a prerelease would let a release GitHub has marked as one take the tags + // while allow-prereleases is off. + const { github } = await actionsFor(t, [ + { method: 'GET', path: RELEASE_PATH('v1.3.0'), status: 403, body: { message: 'Forbidden' } }, + ]) + + await assert.rejects( + readPrereleaseFlag({ github, ...REPO, tag: 'v1.3.0' }), + /Could not tell whether v1\.3\.0 is a prerelease/ + ) + }) + }) + + describe('resolvedNothing', () => { + it('reports empty lists and no version', () => { + const { tags, skipped, version } = resolvedNothing('1.2 has 2 parts; at least three are needed.') + + assert.deepEqual(tags, []) + assert.deepEqual(skipped, []) + assert.equal(version, '') + }) + + it('carries the reason as the one notice to log', () => { + const { notices } = resolvedNothing('nonsense is not a numeric version.') + + assert.deepEqual(notices, [ + { + level: 'notice', + message: 'Resolved no floating tags: nonsense is not a numeric version.', + }, + ]) + }) + }) + + describe('isPrereleaseRelease', () => { + it('is true for a tag with a prerelease suffix, whatever GitHub says', () => { + const suffixed = readReleaseTag('1.4.0-rc.1') + + assert.equal(isPrereleaseRelease(suffixed, false), true) + assert.equal(isPrereleaseRelease(suffixed, true), true) + }) + + it('falls back to GitHub for a plain version, which the tag cannot answer', () => { + const plain = readReleaseTag('1.4.0') + + assert.equal(isPrereleaseRelease(plain, true), true) + assert.equal(isPrereleaseRelease(plain, false), false) + }) + + it('is false for build metadata, which is not a prerelease', () => { + assert.equal(isPrereleaseRelease(readReleaseTag('1.4.0+build.7'), false), false) + }) + }) + + describe('prereleaseSkipped', () => { + it('names the tag and the input that would change it', () => { + assert.equal( + prereleaseSkipped('1.4.0-rc.1'), + '1.4.0-rc.1 is a prerelease. Set allow-prereleases to change that.' + ) + }) + }) + + describe('decideTags', () => { + const planFor = (tag, depths = [1, 2], prerelease = false) => ({ + release: readReleaseTag(tag), + depths, + prerelease, + tagNames: [], + }) + + it('decides one tag per requested depth, shortest first', () => { + const { tags, skipped, version } = decideTags({ + ...planFor('1.3.0'), + tagNames: ['1.2.1', '1.3.0'], + }) + + assert.deepEqual(tags, ['v1', 'v1.3']) + assert.deepEqual(skipped, []) + assert.equal(version, '1.3.0') + }) + + it('decides only the depths it was given', () => { + assert.deepEqual(decideTags(planFor('1.3.0', [1])).tags, ['v1']) + assert.deepEqual(decideTags(planFor('1.3.0', [2])).tags, ['v1.3']) + }) + + it('collects a notice for each tag it leaves alone', () => { + const { tags, skipped, notices } = decideTags({ + ...planFor('1.2.5'), + tagNames: ['1.3.0', '1.2.5'], + }) + + assert.deepEqual(tags, ['v1.2']) + assert.deepEqual(skipped, ['v1']) + assert.equal(notices.length, 1) + assert.match(notices[0].message, /^v1 stays where it is: 1\.3\.0 is newer/) + }) + + it('passes the prerelease decision down to each tag', () => { + const { tags, skipped } = decideTags({ + ...planFor('1.4.0-rc.1', [1, 2], true), + tagNames: ['1.3.0'], + }) + + assert.deepEqual(tags, ['v1.4'], 'the 1.4 line has nothing released behind it') + assert.deepEqual(skipped, ['v1'], 'the 1.x line does, so a prerelease cannot have it') + }) + }) + + describe('report', () => { + it('writes the three outputs a later step reads', async (t) => { + const { core, outputs } = await actionsFor(t) + + report({ + core, + tag: 'v1.2.5', + resolved: { tags: ['v1.2'], skipped: ['v1'], version: '1.2.5', notices: [] }, + }) + + assert.deepEqual(outputs(), { tags: 'v1.2', skipped: 'v1', version: '1.2.5' }) + }) + + it('logs every notice it was given', async (t) => { + const { core, logged } = await actionsFor(t) + + report({ + core, + tag: 'v1.2.5', + resolved: { + tags: [], + skipped: ['v1'], + version: '1.2.5', + notices: [{ level: 'notice', message: 'v1 stays where it is' }], + }, + }) + + assert.match(logged(), /::notice::v1 stays where it is/) + }) + + it('names the tag in the log when the version was rejected', async (t) => { + const { core, logged } = await actionsFor(t) + + report({ + core, + tag: 'latest', + resolved: { tags: [], skipped: [], version: '', notices: [] }, + }) + + assert.match(logged(), /Resolved for latest: none/) + }) + }) + + describe('resolveFloatingTags', () => { + describe('each tag is decided on its own line', () => { + const tagNames = ['1.0.0', '1.2.1', '1.3.0', '2.0.0'] + + it('gives every requested tag to the newest release', () => { + const { tags, skipped } = resolveFloatingTags({ tag: '1.3.0', tagNames }) + + assert.deepEqual(tags, ['v1', 'v1.3']) + assert.deepEqual(skipped, []) + }) + + it('reads a v-prefixed tag the same way', () => { + const { tags, skipped } = resolveFloatingTags({ tag: 'v1.3.0', tagNames }) + + assert.deepEqual(tags, ['v1', 'v1.3']) + assert.deepEqual(skipped, []) + }) + + it('gives a patch on an older line its minor tag and leaves the major alone', () => { + const { tags, skipped } = resolveFloatingTags({ tag: '1.2.5', tagNames: ['1.3.0', '1.2.5'] }) + + assert.deepEqual(tags, ['v1.2']) + assert.deepEqual(skipped, ['v1']) + }) + + it('does not move a tag backwards when a release is published out of order', () => { + const { tags, skipped } = resolveFloatingTags({ tag: '1.1.1', tagNames: ['1.2.0', '1.1.1'] }) + + assert.deepEqual(tags, ['v1.1']) + assert.deepEqual(skipped, ['v1']) + }) + + it('does not let a newer major hold a lower line back', () => { + const { tags, skipped } = resolveFloatingTags({ tag: '1.2.4', tagNames: ['2.1.0', '1.2.4'] }) + + assert.deepEqual(tags, ['v1', 'v1.2']) + assert.deepEqual(skipped, []) + }) + + it('still compares a version that would fall past a release-list cap', () => { + // Reading releases rather than tags missed 1.5.0 here, because a release list is ordered by + // publication date and capped, and moved v1 back onto 1.2.5. + const { tags, skipped } = resolveFloatingTags({ + tag: '1.2.5', + tagNames: ['3.1.0', '3.0.0', '2.9.0', '1.5.0', '1.2.5'], + }) + + assert.deepEqual(tags, ['v1.2']) + assert.deepEqual(skipped, ['v1']) + }) + }) + + describe('a fourth version part is a hotfix in the same minor line', () => { + it('gives the minor tag to the hotfix', () => { + const { tags, skipped } = resolveFloatingTags({ + tag: '1.2.3.1', + tagNames: ['1.2.2', '1.2.3', '1.2.3.1'], + }) + + assert.deepEqual(tags, ['v1', 'v1.2']) + assert.deepEqual(skipped, []) + }) + + it('moves nothing when the patch is republished after its hotfix', () => { + const { tags, skipped } = resolveFloatingTags({ + tag: '1.2.3', + tagNames: ['1.2.3', '1.2.3.1', '1.3.0'], + }) + + assert.deepEqual(tags, []) + assert.deepEqual(skipped, ['v1', 'v1.2']) + }) + + it('gives the tags to the next patch after a hotfix', () => { + const { tags, skipped } = resolveFloatingTags({ + tag: '1.2.4', + tagNames: ['1.2.3', '1.2.3.1', '1.2.4'], + }) + + assert.deepEqual(tags, ['v1', 'v1.2']) + assert.deepEqual(skipped, []) + }) + }) + + /** + * Everything the individual functions decide has its own cases above. What is left to + * resolveFloatingTags is the order it asks them in, and that order is observable: whether a bad + * input is reported or thrown depends entirely on which check runs first. + */ + describe('the order it checks things in', () => { + it('reports a bad tag rather than throwing over the levels beside it', () => { + const { tags, notices } = resolveFloatingTags({ tag: 'latest', levels: 'major hotfix' }) + + assert.deepEqual(tags, []) + assert.match(notices[0].message, /latest is not a numeric version/) + }) + + it('throws over a bad level before deciding a prerelease is not allowed', () => { + // Both are wrong. The level throws, so a caller fixes its own configuration first rather + // than reading a notice that says the release was skipped. + assert.throws( + () => resolveFloatingTags({ tag: '1.3.0-rc.1', levels: 'major hotfix' }), + /Unknown level 'hotfix'/ + ) + }) + + it('throws over a bad level even for a release it would have resolved', () => { + assert.throws( + () => resolveFloatingTags({ tag: '1.3.0', levels: 'major hotfix' }), + /Unknown level 'hotfix'/ + ) + }) + }) + + // readReleaseTag owns which versions are rejected and why. What is left here is a rejection + // becoming an empty result with the reason logged. + describe('a rejected tag', () => { + it('resolves nothing and reports the reason readReleaseTag gave', () => { + const { tags, skipped, version, notices } = resolveFloatingTags({ + tag: '1.2', + tagNames: ['1.3.0'], + }) + + assert.deepEqual(tags, []) + assert.deepEqual(skipped, []) + assert.equal(version, '', 'nothing is reported as the released version') + assert.equal( + notices[0].message, + 'Resolved no floating tags: 1.2 has 2 parts; at least three are needed.' + ) + }) + }) + + // readLevels owns which names are levels and what order they come back in. What is left here is + // whether resolveFloatingTags acts on them. + describe('levels', () => { + const tagNames = ['1.2.1', '1.3.0'] + + it('resolves only the major when that is the only level named', () => { + const { tags } = resolveFloatingTags({ tag: '1.3.0', tagNames, levels: 'major' }) + + assert.deepEqual(tags, ['v1']) + }) + + it('resolves only the minor when that is the only level named', () => { + const { tags } = resolveFloatingTags({ tag: '1.3.0', tagNames, levels: 'minor' }) + + assert.deepEqual(tags, ['v1.3']) + }) + + it('lets a bad level fail the call instead of resolving a shorter set', () => { + assert.throws( + () => resolveFloatingTags({ tag: '1.3.0', levels: 'major hotfix' }), + /Unknown level 'hotfix'/ + ) + }) + }) + + describe('prereleases', () => { + it('reads build metadata as a released version, not a prerelease', () => { + const { tags, skipped } = resolveFloatingTags({ + tag: '1.4.0+build.7', + tagNames: ['1.3.0', '1.4.0+build.7'], + }) + + assert.deepEqual(tags, ['v1', 'v1.4']) + assert.deepEqual(skipped, []) + }) + + it('resolves nothing for a suffixed tag', () => { + const { tags, skipped } = resolveFloatingTags({ + tag: '1.4.0-rc.1', + tagNames: ['1.3.0'], + }) + + assert.deepEqual(tags, []) + assert.deepEqual(skipped, [], 'it stops before any tag is considered') + }) + + it('resolves nothing for a plain version GitHub marks as a prerelease', () => { + const { tags, skipped } = resolveFloatingTags({ + tag: '1.3.0', + flaggedPrerelease: true, + tagNames: ['1.2.1', '1.3.0'], + }) + + assert.deepEqual(tags, []) + assert.deepEqual(skipped, []) + }) + + it('never lets an outstanding prerelease block a released version', () => { + const { tags, skipped } = resolveFloatingTags({ + tag: '1.3.1', + tagNames: ['1.3.0', '1.3.1', '1.9.0-rc.1'], + }) + + assert.deepEqual(tags, ['v1', 'v1.3']) + assert.deepEqual(skipped, []) + }) + + describe('with allow-prereleases on', () => { + const allowPrereleases = true + + it('gives a tag to a line that has had no released version', () => { + // v1.4 has nothing released behind it; v1 stays on 1.3.0. + const { tags, skipped } = resolveFloatingTags({ + tag: '1.4.0-rc.1', + tagNames: ['1.3.0', '1.4.0-rc.1'], + allowPrereleases, + }) + + assert.deepEqual(tags, ['v1.4']) + assert.deepEqual(skipped, ['v1']) + }) + + it('lets one prerelease replace another, with the earlier floating tags present', () => { + // The repository holds v1 and v1.0 from rc.1 by now, and neither may count as a release. + const { tags, skipped } = resolveFloatingTags({ + tag: '1.0.0-rc.2', + tagNames: ['1.0.0-rc.1', 'v1', 'v1.0', '1.0.0-rc.2'], + allowPrereleases, + }) + + assert.deepEqual(tags, ['v1', 'v1.0']) + assert.deepEqual(skipped, []) + }) + + it('lets one prerelease replace another', () => { + const { tags, skipped } = resolveFloatingTags({ + tag: '1.4.0-rc.2', + tagNames: ['1.3.0', '1.4.0-rc.1', '1.4.0-rc.2'], + allowPrereleases, + }) + + assert.deepEqual(tags, ['v1.4']) + assert.deepEqual(skipped, ['v1']) + }) + + it('gives a brand new major line to its own prerelease', () => { + const { tags, skipped } = resolveFloatingTags({ + tag: '2.0.0-rc.1', + tagNames: ['1.3.0', '2.0.0-rc.1'], + allowPrereleases, + }) + + assert.deepEqual(tags, ['v2', 'v2.0']) + assert.deepEqual(skipped, []) + }) + + it('leaves the major alone when a higher line has shipped', () => { + const { tags, skipped } = resolveFloatingTags({ + tag: '1.4.0-rc.1', + tagNames: ['1.5.0', '1.4.0-rc.1'], + allowPrereleases, + }) + + assert.deepEqual(tags, ['v1.4']) + assert.deepEqual(skipped, ['v1']) + }) + + it('is not the released version that blocks itself', () => { + const { tags, skipped } = resolveFloatingTags({ + tag: '1.3.0', + flaggedPrerelease: true, + tagNames: ['1.2.1', '1.3.0'], + allowPrereleases, + }) + + assert.deepEqual(tags, ['v1.3']) + assert.deepEqual(skipped, ['v1']) + }) + + it('never takes a tag from the released version of the same number', () => { + // A released 1.4.0 keeps v1.4 when its own rc is published afterwards. + const { tags, skipped } = resolveFloatingTags({ + tag: '1.4.0-rc.1', + tagNames: ['1.3.0', '1.4.0', '1.4.0-rc.1'], + allowPrereleases, + }) + + assert.deepEqual(tags, []) + assert.deepEqual(skipped, ['v1', 'v1.4']) + }) + + it('never takes a tag once its line has had a released version', () => { + const { tags, skipped } = resolveFloatingTags({ + tag: '1.4.1-rc.1', + tagNames: ['1.3.0', '1.4.0', '1.4.1-rc.1'], + allowPrereleases, + }) + + assert.deepEqual(tags, []) + assert.deepEqual(skipped, ['v1', 'v1.4']) + }) + }) + }) + + describe('when the repository has no tags', () => { + it('resolves every requested tag, since nothing can be newer', () => { + // Only a repository that genuinely has none reaches this: readTagNames throws rather than + // answering with an empty list when it could not read them. + const { tags, skipped } = resolveFloatingTags({ tag: '1.3.0', tagNames: [] }) + + assert.deepEqual(tags, ['v1', 'v1.3']) + assert.deepEqual(skipped, []) + }) + }) + + describe('the version output', () => { + it('carries the released version without its v', () => { + assert.equal(resolveFloatingTags({ tag: 'v1.2.3', tagNames: [] }).version, '1.2.3') + }) + + it('is empty when the tag was rejected', () => { + assert.equal(resolveFloatingTags({ tag: 'nonsense', tagNames: [] }).version, '') + }) + }) + }) + + describe('run', () => { + it('sends no request at all for a tag that is not a version', async (t) => { + // Nothing the API could say changes the answer, so nothing is asked. + const { github, core, requests, outputs } = await actionsFor(t) + + await run({ + github, + core, + env: { + INPUT_TAG: 'latest', + INPUT_LEVELS: 'major minor', + GITHUB_REPOSITORY: 'stellarwp/plugin-toolbox', + }, + }) + + assert.deepEqual(requests, []) + assert.deepEqual(outputs(), { tags: '', skipped: '', version: '' }) + }) + + it('sends no request for a suffixed prerelease it is not allowed to move', async (t) => { + // The suffix settles both questions: it is a prerelease, and prereleases are not allowed. + const { github, core, requests } = await actionsFor(t) + + await run({ + github, + core, + env: { + INPUT_TAG: '1.4.0-rc.1', + INPUT_LEVELS: 'major minor', + GITHUB_REPOSITORY: 'stellarwp/plugin-toolbox', + }, + }) + + assert.deepEqual(requests, []) + }) + + it('reads the release but not the tag list when a flagged prerelease is skipped', async (t) => { + // A plain version needs the release to know, but once it does the tag list cannot matter. + const { github, core, requests } = await actionsFor( + t, + repoWith(['1.2.1', '1.3.0'], { tag: '1.3.0', prerelease: true }) + ) + + await run({ + github, + core, + env: { + INPUT_TAG: '1.3.0', + INPUT_LEVELS: 'major minor', + GITHUB_REPOSITORY: 'stellarwp/plugin-toolbox', + }, + }) + + assert.equal(requests.length, 1) + assert.match(requests[0].path, /\/releases\/tags\//) + }) + + it('fails on a bad level before sending anything', async (t) => { + const { github, core, requests } = await actionsFor(t) + + await assert.rejects( + run({ + github, + core, + env: { + INPUT_TAG: '1.3.0', + INPUT_LEVELS: 'major hotfix', + GITHUB_REPOSITORY: 'stellarwp/plugin-toolbox', + }, + }), + /Unknown level 'hotfix'/ + ) + + assert.deepEqual(requests, []) + }) + + it('reads every tag, following pagination to the last page', async (t) => { + const { github, requests, core, outputs } = await actionsFor(t, [ + { method: 'GET', path: /\/releases\/tags\//, status: 404, body: { message: 'Not Found' } }, + { + method: 'GET', + path: TAGS_PATH, + responses: [ + { + body: tagPage('1.2.0'), + headers: { + link: '; rel="next"', + }, + }, + // Only reachable by following the Link header. It holds the version that has to win, + // so a resolver that stopped at the first page would resolve v1 to 1.2.0 instead. + { body: tagPage('1.9.0') }, + ], + }, + ]) + + await run({ + github, + core, + env: { + INPUT_TAG: '1.2.0', + INPUT_LEVELS: 'major minor', + GITHUB_REPOSITORY: 'stellarwp/plugin-toolbox', + }, + }) + + assert.equal(requests.filter((request) => TAGS_PATH.test(request.path)).length, 2) + assert.deepEqual(outputs(), { tags: 'v1.2', skipped: 'v1', version: '1.2.0' }) + }) + + it('asks the Releases API about the tag it was given', async (t) => { + const { github, requests, core, outputs } = await actionsFor( + t, + repoWith(['1.2.1', '1.3.0'], { tag: 'v1.3.0', prerelease: false }) + ) + + await run({ + github, + core, + env: { + INPUT_TAG: 'v1.3.0', + INPUT_LEVELS: 'major minor', + GITHUB_REPOSITORY: 'stellarwp/plugin-toolbox', + }, + }) + + assert.ok( + requests.some((request) => request.path === RELEASE_PATH('v1.3.0')), + `the release was looked up by its tag, got ${JSON.stringify(requests.map((r) => r.path))}` + ) + assert.deepEqual(outputs(), { tags: 'v1 v1.3', skipped: '', version: '1.3.0' }) + }) + + it('honours the prerelease flag the Releases API reports', async (t) => { + const { github, core, outputs, logged } = await actionsFor( + t, + repoWith(['1.2.1', '1.3.0'], { tag: '1.3.0', prerelease: true }) + ) + + await run({ + github, + core, + env: { + INPUT_TAG: '1.3.0', + INPUT_LEVELS: 'major minor', + GITHUB_REPOSITORY: 'stellarwp/plugin-toolbox', + }, + }) + + assert.deepEqual(outputs(), { tags: '', skipped: '', version: '' }) + assert.match(logged(), /::notice::Resolved no floating tags: 1\.3\.0 is a prerelease/) + }) + + it('reads a tag with no release behind it as not a prerelease', async (t) => { + const { github, core, outputs } = await actionsFor(t, repoWith(['1.2.1', '1.3.0'])) + + await run({ + github, + core, + env: { + INPUT_TAG: '1.3.0', + INPUT_LEVELS: 'major minor', + GITHUB_REPOSITORY: 'stellarwp/plugin-toolbox', + }, + }) + + assert.deepEqual(outputs(), { tags: 'v1 v1.3', skipped: '', version: '1.3.0' }) + }) + + it('fails without writing an output when the tags cannot be read', async (t) => { + const { github, core, outputs } = await actionsFor(t, [ + { method: 'GET', path: /\/releases\/tags\//, status: 404, body: { message: 'Not Found' } }, + { method: 'GET', path: TAGS_PATH, status: 403, body: { message: 'Forbidden' } }, + ]) + + await assert.rejects( + run({ + github, + core, + env: { + INPUT_TAG: '1.2.5', + INPUT_LEVELS: 'major minor', + GITHUB_REPOSITORY: 'stellarwp/plugin-toolbox', + }, + }), + /no floating tag can be moved safely/ + ) + + assert.deepEqual(outputs(), {}, 'move-git-tags is given nothing') + }) + + it('reads the repository it was given', async (t) => { + const { github, requests, core } = await actionsFor(t, [ + { method: 'GET', path: /\/releases\/tags\//, status: 404, body: { message: 'Not Found' } }, + { method: 'GET', path: /\/repos\/stellarwp\/other\/tags(\?|$)/, body: tagPage('1.3.0') }, + ]) + + await run({ + github, + core, + env: { + INPUT_TAG: '1.3.0', + INPUT_LEVELS: 'major', + INPUT_REPOSITORY: 'stellarwp/other', + GITHUB_REPOSITORY: 'stellarwp/plugin-toolbox', + }, + }) + + assert.ok( + requests.every((request) => request.path.startsWith('/repos/stellarwp/other/')), + `every request went to the given repository, got ${JSON.stringify(requests.map((r) => r.path))}` + ) + }) + }) +}) diff --git a/tests/support/actions.js b/tests/support/actions.js new file mode 100644 index 0000000..9b4765d --- /dev/null +++ b/tests/support/actions.js @@ -0,0 +1,229 @@ +'use strict' + +/** + * Builds the two arguments actions/github-script passes to an action's script, as the real things + * rather than as stand-ins. + * + * `github` is a real Octokit from @actions/github, the same client the action is handed, with only + * the network replaced. A test therefore asserts the HTTP requests the action would have sent, and + * a wrong endpoint, a misspelled parameter or a wrong owner shows up as a different request. + * + * `core` is the real @actions/core, writing to a GITHUB_OUTPUT file in a temporary directory, so + * outputs are read back the way a later workflow step would see them. + * + * Both packages are ESM only, so they are loaded with dynamic import and these factories are async. + */ + +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +/** + * Builds the Response a rule describes, as the API would have sent it. + * + * @param {number} status HTTP status, defaulting to 200. + * @param {object} body Value to send as the JSON body. + * @param {object} headers Extra headers, merged over the JSON content type. + * + * @returns {Response} The response for the fetch to return. + */ +function jsonResponse({ status = 200, body = {}, headers = {} }) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }) +} + +/** + * A `fetch` that answers from a list of rules and records every request it was given. + * + * A rule matches on `method` and on `path`, first match winning. A string path has to equal the + * request path exactly; pass a RegExp to match a family of paths. Exactly, because a substring match + * let a rule for `tags%2Fv1` answer the request for `tags%2Fv1.2` and a tag looked like it already + * existed, the same prefix hazard move-git-tags guards against by reading git/ref. + * + * A rule with `responses` answers each matching request with the next entry, repeating the last one, + * which is how a paginated endpoint is described. Anything unmatched answers 404, so a request the + * test did not plan for surfaces as a 404 rather than as a silent success. + * + * @param {object[]} rules `{method, path, status, body, headers}` or `{method, path, responses}`, + * where `path` is an exact string or a RegExp. + * + * @returns {{fetch: Function, requests: object[]}} The fetch, and the requests it recorded. + */ +function recordingFetch(rules = []) { + const requests = [] + const matchCounts = new Map() + + const fetch = async (url, options = {}) => { + const method = (options.method || 'GET').toUpperCase() + const href = String(url) + // The path with the API origin removed, left percent-encoded as it went over the wire. + const requestPath = href.replace(/^https:\/\/api\.github\.com/, '') + + requests.push({ + method, + url: href, + path: requestPath, + body: options.body ? JSON.parse(options.body) : undefined, + }) + + for (const [index, rule] of rules.entries()) { + const pathMatches = + rule.path instanceof RegExp ? rule.path.test(requestPath) : rule.path === requestPath + + if ((rule.method || 'GET').toUpperCase() !== method || !pathMatches) { + continue + } + + const seen = matchCounts.get(index) ?? 0 + matchCounts.set(index, seen + 1) + + const replies = rule.responses ?? [rule] + + return jsonResponse(replies[Math.min(seen, replies.length - 1)]) + } + + return jsonResponse({ status: 404, body: { message: 'Not Found' } }) + } + + return { fetch, requests } +} + +/** + * A real Octokit with the network replaced. + * + * @param {object[]} rules Passed to recordingFetch. + * + * @returns {Promise<{github: object, requests: object[]}>} The client, and the requests it records. + */ +async function octokitFor(rules = []) { + const { getOctokit } = await import('@actions/github') + const { fetch, requests } = recordingFetch(rules) + + return { github: getOctokit('test-token', { request: { fetch } }), requests } +} + +/** + * Reads back the outputs @actions/core appended to its GITHUB_OUTPUT file. + * + * core writes each one as a heredoc, `name< { + if (!collecting) { + return realStdoutWrite(chunk, encoding, callback) + } + + collecting.push(String(chunk)) + + const done = typeof encoding === 'function' ? encoding : callback + if (typeof done === 'function') { + done() + } + + return true + } + } + + collecting = written + + // Nothing collects between tests, so this is right however many times a test called it. + t.after(() => { + collecting = null + }) + + return () => written.join('') +} + +/** + * The real @actions/core, pointed at a fresh GITHUB_OUTPUT file, with its log captured. + * + * setOutput reads GITHUB_OUTPUT on every call, so pointing it at a new file per test keeps the + * outputs of one test out of another even though the module itself is cached. + * + * info, notice and warning write to stdout, and notice and warning write the `::notice::` and + * `::warning::` commands the runner turns into annotations on the job. Left alone, a passing test + * run annotates its own workflow with the messages the actions logged. Collecting the writes instead + * keeps the run clean and lets a test assert what was logged. stdout is restored when the test ends, + * before the runner reports the result. + * + * @param {object} t The node:test context, used to restore stdout afterwards. + * + * @returns {Promise<{core: object, outputs: Function, logged: Function}>} The toolkit, a reader for + * its outputs, and a reader for everything it wrote to the log. + */ +async function coreWithOutputs(t) { + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'toolbox-output-')), 'output') + fs.writeFileSync(file, '') + process.env.GITHUB_OUTPUT = file + + const core = await import('@actions/core') + + return { core, outputs: () => readOutputs(file), logged: captureStdout(t) } +} + +/** + * Both arguments actions/github-script passes to a script, ready for one test. + * + * @param {object} t The node:test context. + * @param {object[]} rules Passed to recordingFetch. + * + * @returns {Promise<{github: object, requests: object[], core: object, outputs: Function, + * logged: Function}>} Everything a test needs: the client, the requests it records, the + * toolkit, and readers for its outputs and its log. + */ +async function actionsFor(t, rules = []) { + const { github, requests } = await octokitFor(rules) + const { core, outputs, logged } = await coreWithOutputs(t) + + return { github, requests, core, outputs, logged } +} + +module.exports = { actionsFor, octokitFor, coreWithOutputs, recordingFetch, readOutputs } diff --git a/tests/support/actions.test.js b/tests/support/actions.test.js new file mode 100644 index 0000000..40c021f --- /dev/null +++ b/tests/support/actions.test.js @@ -0,0 +1,35 @@ +'use strict' + +const { describe, it } = require('node:test') +const assert = require('node:assert') + +const { actionsFor } = require('./actions.js') + +describe('tests/support/actions', () => { + describe('the captured log', () => { + it('holds what the toolkit wrote during the test', async (t) => { + const { core, logged } = await actionsFor(t) + + core.warning('a warning') + + assert.match(logged(), /::warning::a warning/) + }) + + it('survives a test that builds two sets of actions', async (t) => { + /** + * Replacing the stdout write per call meant the second call captured the already-replaced + * write as the original. Restoring put that back, so stdout stayed captured after the test and + * the runner's own output went into a dead buffer: every later test in the file disappeared + * from the report while the run still passed. + */ + const first = await actionsFor(t) + const second = await actionsFor(t) + + second.core.info('from the second') + + // There is one @actions/core for the process, so the newest capture is the one collecting. + assert.match(second.logged(), /from the second/, 'the newest capture still works') + assert.equal(first.logged(), '', 'the earlier one stopped when the second began') + }) + }) +})