diff --git a/.github/actions/release-merge/README.md b/.github/actions/release-merge/README.md index 0b9a211..fa5bb54 100644 --- a/.github/actions/release-merge/README.md +++ b/.github/actions/release-merge/README.md @@ -16,17 +16,26 @@ for it**, set with `base-branch`: with: ref: ${{ steps.merge.outputs.base-sha }} -# Build the plugin here, then publish the release at ${{ steps.merge.outputs.base-sha }}. +# Build the plugin here, then publish the release at the merged commit: +- uses: stellarwp/plugin-toolbox/.github/actions/release-publish@v1 + with: + version: ${{ inputs.version }} + target: ${{ steps.merge.outputs.base-sha }} ``` -Publishing the GitHub release is not this action's: the caller does it after its build, at the -commit this action leaves on the base branch (`base-sha`), so the release can carry the build and is -published only once it exists. +Publishing the GitHub release is [release-publish](../release-publish/README.md)'s, run after this +action and after the caller's build, at the commit this action leaves on the base branch +(`base-sha`), so the release can carry the build and is published only once it exists. GiveWP's +deploy workflow is that chain: release-merge, the build, the wordpress.org deploy, then +release-publish with the zip attached. It is the part of a deploy that is the same for every plugin repository, on a base branch plus `develop` model or on a base branch alone (`develop-branch: ''` skips the merge back). Building the plugin, uploading it anywhere and announcing it are the caller's steps, after the action, on a checkout of `base-sha`. +[`templates/workflows/release.yml`](../../../templates/workflows/release.yml) is the complete deploy +workflow, this action, the checkout, the build, then release-publish, ready to copy into a plugin +repository. ## What it does @@ -46,10 +55,10 @@ In this order, stopping at the first failure: same generated lines. Skipped when `develop-branch` is empty, for a repository that has no develop branch. -The merge commit on the base branch is the `base-sha` output: what the caller checks out, builds -from and tags. GitHub makes both merge commits under the identity of `token`, `github-actions[bot]` -for the default token, and signs them. The API never fast-forwards, so each release is one merge -commit in the history. +The merge commit on the base branch is the `base-sha` output: what the caller checks out and builds +from, and what release-publish tags. GitHub makes both merge commits under the identity of `token`, +`github-actions[bot]` for the default token, and signs them. The API never fast-forwards, so each +release is one merge commit in the history. The action reads and writes no checkout: it needs none before it, and leaves whatever checkout the job has as it was. Check out `base-sha` after it for the steps that build the release. @@ -63,7 +72,7 @@ does: already contains the head, and nothing is written. - A version already published counts as a retry only when its tag contains the release branch, that is, when it was published from this branch. See [Versions it refuses](#versions-it-refuses). - The caller's publish then has nothing to do. + release-publish then skips the release. - A conflict resolved by hand on the branch, then pushed, is found as an already-merged branch on the next run, which goes on to the step that had not run. @@ -109,8 +118,8 @@ fails the step without the marker; running the step again is enough. - **An empty version.** A tool that dispatches the workflow for a release with no version set sends an empty one. When the workflow declares `version` as required, GitHub refuses the dispatch itself; when it is optional, the action fails the run before merging. -- **A version whose tag exists without a published release.** Publishing would release whatever - commit the tag points at. Delete the tag or release another version. +- **A version whose tag exists without a published release.** release-publish would release + whatever commit the tag points at. Delete the tag or release another version. - **A version already released from another commit.** When the tag exists with a published release and does not contain the release branch, the version went out before this branch, and the branch was never bumped past it: a branch cut from `main` right after a release still carries that @@ -138,7 +147,7 @@ two different branches (when there is a develop), and the release branch cannot |---|---| | `version` | The version released. | | `branch` | The release branch merged. | -| `base-sha` | The commit on the base branch that holds the release: the merge commit, or the base's tip on a retry that found the merge done. The commit to check out, build from and publish the release at. | +| `base-sha` | The commit on the base branch that holds the release: the merge commit, or the base's tip on a retry that found the merge done. The commit to check out and build from; pass it to release-publish as `target`. | | `conflict` | The branch a merge conflicted on, or empty. A conflict fails the step, so read it with `continue-on-error` or in a later `if: failure()` step. | ## One deploy at a time @@ -177,8 +186,8 @@ are protected from deletion and non-fast-forward pushes, and `github.token` writ human gate is not the token: the caller puts the job in an environment with required reviewers, and the release branch reached this step after its review and QA approval. A repository whose rules still need another actor should pass a GitHub App installation token minted in the run rather than -a personal access token. A release published with the default `github.token` triggers no `release` -workflow; one published with a personal access token or a GitHub App token does. +a personal access token. What the token needs for the tag is in +[release-publish → The token](../release-publish/README.md#the-token). ## How it runs diff --git a/.github/actions/release-merge/action.yml b/.github/actions/release-merge/action.yml index 6faa341..6713e71 100644 --- a/.github/actions/release-merge/action.yml +++ b/.github/actions/release-merge/action.yml @@ -1,7 +1,8 @@ # Merges a release: the release branch into the base branch (`main`, or `master` where that is the # name), then the base branch back into develop. The part of a deploy that is the same for every -# plugin; what happens after the merge (building, publishing the GitHub release, wordpress.org, -# announcements) is the caller's, at the commit this one leaves on the base (`base-sha`). +# plugin; what happens after the merge (building, wordpress.org, announcements) is the caller's, and +# publishing the GitHub release is the release-publish action's, at the commit this one leaves on the +# base (`base-sha`). # # The checks and the merges live in release-merge.js, run by actions/github-script, so there is no # dependency to install and nothing to build. Everything goes through the GitHub API: the branch, @@ -23,7 +24,7 @@ inputs: paths.versions is read, and a different version in any of them is refused. A pre-release such as 4.17.0-beta.1 and an empty value are refused before anything is merged, and so is a version whose tag already exists, unless that tag's release was published from this branch, which makes - the run a retry. Publishing the release is the caller's, after this action. + the run a retry. The release itself is published by release-publish, after this action. required: true base-branch: description: 'The branch the release is merged into: `main`, or `master` where that is the name.' @@ -55,8 +56,8 @@ outputs: base-sha: description: >- The commit on the base branch that holds the release: the merge commit, or the base's tip on a - retry that found the merge already done. The commit to check out, build from and publish the - release at. + retry that found the merge already done. The commit to check out and build from; pass it to + release-publish as `target`. value: ${{ steps.merge.outputs.base-sha }} conflict: description: >- diff --git a/.github/actions/release-publish/README.md b/.github/actions/release-publish/README.md new file mode 100644 index 0000000..a795355 --- /dev/null +++ b/.github/actions/release-publish/README.md @@ -0,0 +1,133 @@ +# release-publish + +Publishes the GitHub release for a version at a commit: the tag and the release in one step, with +the title, the notes and the assets the caller chooses. It is the second half of a deploy, after +[release-merge](../release-merge/README.md) has merged the release and the caller has built it: + +```yaml +- uses: stellarwp/plugin-toolbox/.github/actions/release-merge@v1 + id: merge + with: + branch: ${{ inputs.branch }} + version: ${{ inputs.version }} + +- uses: actions/checkout@v6 + with: + ref: ${{ steps.merge.outputs.base-sha }} + +# Build the plugin here, from the merged commit. + +- uses: stellarwp/plugin-toolbox/.github/actions/release-publish@v1 + with: + version: ${{ inputs.version }} + target: ${{ steps.merge.outputs.base-sha }} + files: | + build/my-plugin.zip +``` + +`target` is the commit the release is made at: release-merge's `base-sha` output, as above, which is +also the commit checked out and built between the two actions. The tag is made at that commit, +whatever the base branch points to by the time this runs. +[`templates/workflows/release.yml`](../../../templates/workflows/release.yml) is the complete deploy +workflow around the two actions, ready to copy into a plugin repository. + +The action also runs on its own, for a release a person merged by hand: check out the branch, +record its commit, and pass that as `target` (a branch name would be resolved to its tip when the +action runs, after the checkout): + +```yaml +- uses: actions/checkout@v6 + with: + ref: ${{ inputs.branch }} + persist-credentials: false + +- id: head + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + +- uses: stellarwp/plugin-toolbox/.github/actions/release-publish@v1 + with: + version: ${{ inputs.version }} + target: ${{ steps.head.outputs.sha }} +``` + +## What it does + +In this order, stopping at the first failure: + +1. Checks the inputs: the version (three or four numeric parts), the target, and that every file + in `files` and the `notes-file` exist in the checkout. +2. Reads the release for the version. One that exists is skipped, so a run can be repeated after a + late failure; a tag that exists without a release is refused, since publishing would release + whatever commit the tag points at. +3. Resolves the target to a commit: a branch name becomes its tip. +4. Decides whether the release takes "Latest": it does, unless the repository's current latest + release is a higher version, as when a hotfix goes out on an older line after a newer version + is already released. +5. Without `files`, creates the release at the commit: tag and title, the notes from `notes-file` + or the ones GitHub generates from the pull requests since the previous release. +6. With `files`, creates the release as a **draft**, uploads every file, then publishes it. A draft + makes no tag, so an upload that fails is undone by deleting the draft: nothing is left behind, + and the release is never public without its files. That is also what a repository with + immutable releases needs, since a published release cannot be changed afterwards. + +## Inputs + +| Input | Required | Default | What it is | +|---|---|---|---| +| `version` | yes | | The version being released: three or four numeric parts. It becomes the tag. | +| `target` | yes | | The commit to publish at: a full commit hash, or a branch name for its tip. | +| `title` | no | the version | The release title, e.g. `Maintenance Release 6.18.3`. | +| `notes-file` | no | | A file in the checkout whose contents are the notes, e.g. this version's changelog entry. Empty: GitHub generates them. | +| `files` | no | | Assets to attach, one path per line, relative to the checkout. | +| `token` | no | `github.token` | The token the release is created with. Needs `contents: write`. See [The token](#the-token). | + +## Outputs + +| Output | What it is | +|---|---| +| `published` | `true` when the release was created, `skipped` when it already existed. | +| `release-url` | The release page. | +| `sha` | The commit the release was created at; empty when it already existed. | + +## Running it again + +A release that already exists is skipped and its URL is still reported, so a deploy that failed +after publishing is rerun as it is. An existing release is not changed: a file that was not +attached because the run died between the publish and a later step stays missing, and is added by +hand, or not at all on a repository with immutable releases. With `files`, the window is closed: +the release is only published once every file is on it. + +## The token + +The job needs `contents: write`. The default `github.token` is enough when the repository's tag +rules allow it to create a tag: a tag ruleset should block deletion and updates, not creation. +GiveWP's rules are the reference shape (branches protected from deletion and non-fast-forward +pushes, no tag rule), and `github.token` publishes there. + +One case the default token cannot cover: a release at a commit that adds or changes a file under +`.github/workflows/` relative to the repository's default branch. GitHub requires a token allowed +to modify workflows for that (a GitHub App installation token with the Workflows permission, or a +personal access token with the `workflow` scope) and answers 404 to `github.token`. After +release-merge the commit published is the base branch's own tip, so there is no such difference and +`github.token` publishes; the case is the publish of a branch a person merged by hand, when that +branch changed a workflow. + +A release published with the default `github.token` triggers no `release` workflow: GitHub does +not start workflows from events a workflow's own token caused. The run that merged and published +the release is the one that deploys it, so a repository that also has an `on: release` deploy does +not deploy it twice. **A personal access token or a GitHub App token does trigger `on: release`**: +a repository whose tag rules require one of those, because `github.token` cannot be on a ruleset's +bypass list, deploys twice unless the `on: release` workflow is given the same concurrency group +and ignores releases created by the bot, or is retired. Prefer a GitHub App installation token +minted in the run over a personal access token: it is short-lived and scoped to the run. + +## How it runs + +One [actions/github-script](https://github.com/actions/github-script) step runs +[`release-publish.js`](./release-publish.js). The inputs reach the script through environment +variables, never interpolated into it, so a crafted value is data rather than part of the program. +Everything goes through github-script's Octokit; the checkout is only read, for the notes file and +the assets. + +The tests, `node --test` from the repository root, run the script against a recorded client and +real files in a temporary directory. diff --git a/.github/actions/release-publish/action.yml b/.github/actions/release-publish/action.yml new file mode 100644 index 0000000..2a064d0 --- /dev/null +++ b/.github/actions/release-publish/action.yml @@ -0,0 +1,84 @@ +# Publishes the GitHub release for a version at a given commit: tag and release in one step, with the +# title, the notes and the assets the caller chooses. The second half of a deploy: release-merge +# merges the release and leaves the commit to publish at in its `base-sha` output, the caller builds +# from the checkout, then this action publishes with the build attached. +# +# The logic lives in release-publish.js, run by actions/github-script, so there is no dependency to +# install and nothing to build. Everything goes through the GitHub API; the checkout is only read, +# for the notes file and the assets. +name: Release publish +description: Publishes the GitHub release for a version at a commit, with a title, notes and assets + +inputs: + version: + description: >- + The version being released: three or four numeric parts, such as 4.17.0 or 4.17.0.1. It becomes + the tag. A pre-release such as 4.17.0-beta.1 is refused, and so is a version whose tag already + exists without a published release. A version already published is skipped, so a run can be + repeated after a late failure. + required: true + target: + description: >- + The commit to publish at: a full commit hash, or a branch name for its tip. After release-merge, + pass its `base-sha` output; for a release a person merged by hand, the branch. The tag is made + at this commit, whatever the branch points to by the time this runs. + required: true + title: + description: 'The release title. The version when empty.' + required: false + default: '' + notes-file: + description: >- + A file in the checkout whose contents are the release notes, e.g. the changelog entry of this + version. When empty, GitHub generates the notes from the pull requests since the previous + release. + required: false + default: '' + files: + description: >- + Assets to attach, one path per line, relative to the checkout. With assets the release is created + as a draft, the files are uploaded, and the draft is published last: a draft makes no tag, so an + upload that fails leaves nothing behind. Every path is checked before anything is created. + required: false + default: '' + token: + description: >- + Token the release is created with. It needs `contents: write` on the repository, so the job + needs that permission too. A release published with the default token triggers no `release` + workflow; one published with a personal access token or a GitHub App token does. + required: false + default: ${{ github.token }} + +outputs: + published: + description: '`true` when the release was created, `skipped` when it already existed.' + value: ${{ steps.publish.outputs.published }} + release-url: + description: 'The GitHub release page.' + value: ${{ steps.publish.outputs.release-url }} + sha: + description: 'The commit the release was created at; empty when it already existed.' + value: ${{ steps.publish.outputs.sha }} + +runs: + using: 'composite' + steps: + - name: Publish the GitHub release + id: publish + 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. + ACTION_PATH: ${{ github.action_path }} + # Read from process.env rather than interpolated into the script, so an input is data and + # never part of the program. + INPUT_VERSION: ${{ inputs.version }} + INPUT_TARGET: ${{ inputs.target }} + INPUT_TITLE: ${{ inputs.title }} + INPUT_NOTES_FILE: ${{ inputs.notes-file }} + INPUT_FILES: ${{ inputs.files }} + with: + github-token: ${{ inputs.token }} + script: | + const { runPublish } = require(`${process.env.ACTION_PATH}/release-publish.js`) + await runPublish({ github, core, env: process.env }) diff --git a/.github/actions/release-publish/release-publish.js b/.github/actions/release-publish/release-publish.js new file mode 100644 index 0000000..a02f589 --- /dev/null +++ b/.github/actions/release-publish/release-publish.js @@ -0,0 +1,353 @@ +'use strict' + +/** + * Publishes the GitHub release for a version at a given commit: tag and release in one step, with + * the title, the notes and the assets the caller chooses. + * + * It is the second half of a deploy, after release-merge has merged the release and the caller has + * built it: `target` is the commit release-merge left on the base branch (`base-sha`), or the tip of + * a branch a person merged by hand. The release is created at that commit, not at whatever a branch + * points to when this runs. + * + * With assets, the release is created as a draft, the assets are uploaded, and the draft is + * published last: a draft makes no tag, so an upload that fails leaves nothing behind once the draft + * is deleted, and the release is never public without its files. That is also what a repository + * with immutable releases needs, since a published release cannot be changed afterwards. + * + * Running it again after a late failure is safe: a release that already exists is skipped. + */ + +const fs = require('node:fs') +const path = require('node:path') + +const { STELLARWP_VERSION_PATTERN, compareVersions, validateVersion: validateReleaseVersion } = require('../../lib/versions.js') +const { readRepository, isNotFound, tagExists } = require('../../lib/github.js') + +/** Closes every error message: nothing of this action's was done. */ +const NOTHING = 'Nothing was published.' + +/** + * Reads the version to release: the shared rule, with this action's closing line. + * + * @param {string} version The `version` input. + * + * @throws {Error} When it is empty or not three or four numeric parts. + * + * @returns {string} The version, without surrounding whitespace. + */ +function validateVersion(version) { + return validateReleaseVersion(version, NOTHING) +} + +/** + * Reads the `target` input: the commit the release is made at, as a full hash or a branch name. + * + * @param {string} target The input value. + * + * @throws {Error} When it is empty or a full ref. + * + * @returns {string} The target, without surrounding whitespace. + */ +function readTarget(target) { + const trimmed = String(target ?? '').trim() + + if (trimmed === '') { + throw new Error( + "No target was given: pass the commit to publish at, release-merge's base-sha output or a branch " + + `name. ${NOTHING}` + ) + } + + if (trimmed.startsWith('refs/')) { + throw new Error(`Refusing target '${trimmed}': pass a commit hash or a branch name, not a ref. ${NOTHING}`) + } + + return trimmed +} + +/** + * Whether a target is a full commit hash, which needs no lookup. + * + * @param {string} target The target. + * + * @returns {boolean} True for 40 hex digits. + */ +function isSha(target) { + return /^[0-9a-f]{40}$/.test(target) +} + +/** + * The commit a target names: the hash itself, or the tip of the branch. + * + * @param {object} options + * @param {object} options.github The Octokit github-script supplies. + * @param {string} options.owner The repository owner. + * @param {string} options.repo The repository name. + * @param {string} options.target The target, from readTarget. + * + * @throws {Error} When it is neither a hash nor a branch of the repository. + * + * @returns {Promise} The commit hash. + */ +async function resolveTarget({ github, owner, repo, target }) { + if (isSha(target)) { + return target + } + + try { + const { data } = await github.rest.repos.getBranch({ owner, repo, branch: target }) + + return data.commit.sha + } catch (error) { + if (isNotFound(error)) { + throw new Error(`'${target}' is not a commit hash, and the repository has no branch by that name. ${NOTHING}`) + } + + throw new Error(`Reading branch ${target} failed (HTTP ${error.status}): ${error.message}. ${NOTHING}`) + } +} + +/** + * Reads the `title` input, the version when it is empty. + * + * @param {string} title The input value. + * @param {string} version The version, validated. + * + * @returns {string} The release title. + */ +function readTitle(title, version) { + const trimmed = String(title ?? '').trim() + + return trimmed === '' ? version : trimmed +} + +/** + * Reads the notes file, when one was given. + * + * @param {string} input The `notes-file` input, a path relative to the checkout. + * @param {string} cwd The checkout. + * + * @throws {Error} When the file is not there. + * + * @returns {string|null} The notes, or null to let GitHub generate them. + */ +function readNotesFile(input, cwd) { + const file = String(input ?? '').trim() + + if (file === '') { + return null + } + + try { + return fs.readFileSync(path.resolve(cwd, file), 'utf8') + } catch (error) { + throw new Error(`The notes file ${file} could not be read: ${error.message}. ${NOTHING}`) + } +} + +/** + * Reads the `files` input: the assets to attach, one path per line, relative to the checkout. Every + * one is checked before anything is created, so a missing file fails the run with nothing to undo. + * + * @param {string} input The input value. + * @param {string} cwd The checkout. + * + * @throws {Error} When a path is not a file. + * + * @returns {{name: string, file: string, size: number}[]} The assets, in the order given. + */ +function readFiles(input, cwd) { + const lines = String(input ?? '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + + return lines.map((line) => { + const file = path.resolve(cwd, line) + let stat + try { + stat = fs.statSync(file) + } catch { + stat = null + } + if (!stat?.isFile()) { + throw new Error(`${line}, listed in files, is not a file in the checkout. ${NOTHING}`) + } + + return { name: path.basename(file), file, size: stat.size } + }) +} + +/** + * The tag of the repository's current latest release, or null when it has none. + * + * @param {object} options + * @param {object} options.github The Octokit github-script supplies. + * @param {string} options.owner The repository owner. + * @param {string} options.repo The repository name. + * + * @throws {Error} When the read fails for any reason but 404. + * + * @returns {Promise} The latest release's tag. + */ +async function latestReleaseTag({ github, owner, repo }) { + try { + const { data } = await github.rest.repos.getLatestRelease({ owner, repo }) + + return data.tag_name + } catch (error) { + if (isNotFound(error)) { + return null + } + + throw new Error(`Reading the latest release failed (HTTP ${error.status}): ${error.message}. ${NOTHING}`) + } +} + +/** + * Whether the new release takes "Latest": yes, unless the current latest is a higher version, as + * when a hotfix goes out on an older line after a newer version is released. A latest whose tag is + * not a plain version (a `v` prefix, a name) is not compared, and the new release takes it. + * + * @param {string} version The version being released. + * @param {string|null} latest The current latest release's tag. + * + * @returns {'true'|'false'} The `make_latest` value the API takes. + */ +function makeLatest(version, latest) { + if (latest && STELLARWP_VERSION_PATTERN.test(latest) && compareVersions(version, latest) < 0) { + return 'false' + } + + return 'true' +} + +/** + * Publishes the release, unless it already exists. + * + * @param {object} options + * @param {object} options.github The Octokit github-script supplies, authenticated with the token. + * @param {object} options.core The @actions/core toolkit, for the log, the outputs and the summary. + * @param {object} options.env The environment the action manifest put its inputs in. + * @param {string} options.cwd The checkout, the working directory by default. + * + * @throws {Error} When an input is wrong, a file is missing, the tag exists without a release, or + * an API call fails. + * + * @returns {Promise} Resolves once the release exists, or was found to exist already. + */ +async function runPublish({ github, core, env, cwd = process.cwd() }) { + const version = validateVersion(env.INPUT_VERSION) + const target = readTarget(env.INPUT_TARGET) + const title = readTitle(env.INPUT_TITLE, version) + const notes = readNotesFile(env.INPUT_NOTES_FILE, cwd) + const files = readFiles(env.INPUT_FILES, cwd) + const { owner, repo } = readRepository(env, NOTHING) + + let existing = null + try { + existing = (await github.rest.repos.getReleaseByTag({ owner, repo, tag: version })).data + } catch (error) { + if (!isNotFound(error)) { + throw new Error(`Reading the release for ${version} failed (HTTP ${error.status}): ${error.message}. ${NOTHING}`) + } + } + + if (existing) { + core.info(`${version} is already published (${existing.html_url}): nothing to publish.`) + core.setOutput('published', 'skipped') + core.setOutput('release-url', existing.html_url) + core.setOutput('sha', '') + await core.summary.addRaw(`Release ${version} was already published: ${existing.html_url}`, true).write() + + return + } + + if (await tagExists({ github, owner, repo, version, what: NOTHING })) { + throw new Error( + `The tag ${version} already exists, but there is no published release for it, so publishing ` + + `would release whatever commit the tag points at. Delete the tag or release another version. ${NOTHING}` + ) + } + + const sha = await resolveTarget({ github, owner, repo, target }) + const latest = makeLatest(version, await latestReleaseTag({ github, owner, repo })) + if (latest === 'false') { + core.info(`${version} is below the current latest release, so it is published without taking "Latest".`) + } + + const body = + notes ?? + (await github.rest.repos.generateReleaseNotes({ owner, repo, tag_name: version, target_commitish: sha })).data.body + + let release + if (files.length === 0) { + release = ( + await github.rest.repos.createRelease({ + owner, + repo, + tag_name: version, + target_commitish: sha, + name: title, + body, + make_latest: latest, + }) + ).data + } else { + // A draft makes no tag, so a failed upload can be undone by deleting it, and the release is + // never public without its files. + const draft = ( + await github.rest.repos.createRelease({ + owner, + repo, + tag_name: version, + target_commitish: sha, + name: title, + body, + draft: true, + }) + ).data + + for (const asset of files) { + try { + await github.rest.repos.uploadReleaseAsset({ + owner, + repo, + release_id: draft.id, + name: asset.name, + data: fs.readFileSync(asset.file), + headers: { 'content-type': 'application/octet-stream', 'content-length': asset.size }, + }) + } catch (error) { + await github.rest.repos.deleteRelease({ owner, repo, release_id: draft.id }).catch(() => {}) + throw new Error( + `Uploading ${asset.name} to the release failed (HTTP ${error.status}): ${error.message}. ` + + `The draft was deleted and no tag was made. ${NOTHING}` + ) + } + core.info(`Attached ${asset.name}.`) + } + + release = ( + await github.rest.repos.updateRelease({ owner, repo, release_id: draft.id, draft: false, make_latest: latest }) + ).data + } + + core.info(`Published ${version} at ${sha.slice(0, 7)}: ${release.html_url}`) + core.setOutput('published', 'true') + core.setOutput('release-url', release.html_url) + core.setOutput('sha', sha) + const attached = files.length === 0 ? '' : ` with ${files.map((asset) => `\`${asset.name}\``).join(', ')}` + await core.summary.addRaw(`Published release ${version} at \`${sha.slice(0, 7)}\`${attached}: ${release.html_url}`, true).write() +} + +module.exports = { + runPublish, + validateVersion, + readTarget, + resolveTarget, + readTitle, + readNotesFile, + readFiles, + makeLatest, +} diff --git a/README.md b/README.md index 551f89e..1491384 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,7 @@ Each phase ends with something people can actually use. - [ ] Add `setup-node` for the npm crowd. - [x] Add `release-prep`: version bump, TBD replacement and changelog through pup and the changelogger action, committed to the release branch through the GitHub API. A caller to copy is in `templates/workflows/release-prep.yml`. - [x] Add `release-merge`: the release branch into the base branch, the base back into develop, each step safe to rerun and a conflict reported with a `MERGE_CONFLICT` line. +- [x] Add `release-publish`: the GitHub release for a version at a commit, with a title, notes from a file and assets uploaded before the release is public, skipped when it already exists. The complete deploy workflow to copy, both actions with the build between them, is `templates/workflows/release.yml`. - [ ] Turn every hard-coded repo name, slug, or branch pattern into an input. - [ ] Self-checks: actionlint everything, unit test anything with real logic. - [ ] Tag `v1.0.0`. diff --git a/templates/workflows/release.yml b/templates/workflows/release.yml new file mode 100644 index 0000000..93ca2d9 --- /dev/null +++ b/templates/workflows/release.yml @@ -0,0 +1,91 @@ +name: Release + +# The deploy workflow the release dashboard dispatches, under the name it looks for by default. It +# merges the release and publishes it on GitHub: the release branch into the base branch (`main` +# here, `master` in some repositories), the base branch back into develop, then the GitHub release +# for the version at the merged commit, with the build attached. The build itself is the plugin's: +# add it between the checkout of the merged commit and release-publish, and attach its result with +# `files`. +# +# A file to copy into .github/workflows/ of a plugin repository, not a workflow to call. Rename the +# base branch below where it is `master`. +# +# The inputs are what the release dashboard sends for the deploy step: `branch` and `version`. +# +# Run it locally: you cannot, it pushes to two branches and publishes a release. Start it by hand on +# the release branch, as the dashboard does: +# `gh workflow run release.yml --ref release/M26.jigglypuff -f branch=release/M26.jigglypuff -f version=4.17.0`. + +on: + workflow_dispatch: + inputs: + branch: + description: 'Release branch to merge, e.g. release/M26.jigglypuff' + required: true + type: string + version: + description: 'Version being released, e.g. 4.17.0' + required: true + type: string + +# One deploy at a time for the whole repository, not one per release. Two releases prepared with the +# same version would otherwise merge side by side, and the second would land its changes under a +# version the first has just published. Run one after the other, the second finds the tag published +# and release-merge refuses it before merging. A repository that also deploys from another workflow +# (an `on: release` workflow, for instance) gives both the same group. GitHub keeps one run waiting +# per group and cancels it when another arrives; a cancelled deploy is started again. +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + release: + runs-on: ubuntu-24.04 + # A release that reaches users should wait for a person: give this environment required reviewers + # in the repository settings, and let its branch policy accept the release branches the dashboard + # dispatches on. + environment: live + permissions: + contents: write # release-merge writes the merges, release-publish creates the tag and the release + steps: + # The environment's branch policy protects the branch the run was started on, not the input. + # The two have to be the same branch, so the policy covers what is actually merged and + # published. A failing step, not an `if:` on the job: a skipped job reads as a success to the + # tool that dispatched the run. + - name: Check that the run is on the release branch it merges + env: + BRANCH: ${{ inputs.branch }} + run: | + if [ "$BRANCH" != "$GITHUB_REF_NAME" ]; then + echo "::error::This run is on '$GITHUB_REF_NAME' but was asked to release '$BRANCH'. Start it on the release branch (the dashboard does; by hand, pass --ref)." + exit 1 + fi + + # The merges go through the GitHub API: no checkout is needed before this step. + - uses: stellarwp/plugin-toolbox/.github/actions/release-merge@v1 + id: merge + with: + branch: ${{ inputs.branch }} + version: ${{ inputs.version }} + # base-branch: master + # develop-branch: '' # for a repository without a develop branch + + # The merged commit, the one the release is published at: build and deploy from it, so the + # release below is published only once the build exists, with it attached. + - uses: actions/checkout@v6 + with: + ref: ${{ steps.merge.outputs.base-sha }} + persist-credentials: false + + - uses: stellarwp/plugin-toolbox/.github/actions/release-publish@v1 + id: release + with: + version: ${{ inputs.version }} + target: ${{ steps.merge.outputs.base-sha }} + # title: Maintenance Release ${{ inputs.version }} + # notes-file: release-notes.md + # files: | + # build/my-plugin.zip diff --git a/tests/actions/release-publish/release-publish.test.js b/tests/actions/release-publish/release-publish.test.js new file mode 100644 index 0000000..4f2a657 --- /dev/null +++ b/tests/actions/release-publish/release-publish.test.js @@ -0,0 +1,362 @@ +'use strict' + +const { describe, it } = require('node:test') +const assert = require('node:assert') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +const { + runPublish, + validateVersion, + readTarget, + resolveTarget, + readTitle, + readNotesFile, + readFiles, + makeLatest, +} = require('../../../.github/actions/release-publish/release-publish.js') +const { coreWithOutputs, octokitFor } = require('../../support/actions.js') + +/** The commit release-merge hands over as `base-sha`. */ +const SHA = '0123456789abcdef0123456789abcdef01234567' + +/** The repository paths the action reads and writes, as a real Octokit spells them. */ +const API = { + tag: '/repos/stellarwp/plugin/git/ref/tags%2F4.17.0', + releaseByTag: '/repos/stellarwp/plugin/releases/tags/4.17.0', + latest: '/repos/stellarwp/plugin/releases/latest', + branch: '/repos/stellarwp/plugin/branches/main', + notes: '/repos/stellarwp/plugin/releases/generate-notes', + releases: '/repos/stellarwp/plugin/releases', + release: '/repos/stellarwp/plugin/releases/77', + assets: /^https:\/\/uploads\.github\.com\/repos\/stellarwp\/plugin\/releases\/77\/assets\?name=/, +} + +const RELEASE_URL = 'https://github.com/stellarwp/plugin/releases/tag/4.17.0' + +/** The rules of the common case: no tag, no release yet, a lower latest, notes generated. */ +const FRESH = [ + { method: 'POST', path: API.notes, body: { name: '4.17.0', body: '## What changed\n* Fix (#12)' } }, + { method: 'GET', path: API.latest, body: { tag_name: '4.16.0' } }, +] + +/** + * The environment the manifest hands the script. + * + * @param {object} overrides Inputs to change. + * + * @returns {object} The environment. + */ +function publishEnv(overrides = {}) { + return { + INPUT_VERSION: '4.17.0', + INPUT_TARGET: SHA, + INPUT_TITLE: '', + INPUT_NOTES_FILE: '', + INPUT_FILES: '', + GITHUB_REPOSITORY: 'stellarwp/plugin', + ...overrides, + } +} + +/** + * A checkout holding the files a test names. + * + * @param {object} files Name to contents. + * + * @returns {string} The directory. + */ +function checkoutWith(files = {}) { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'release-publish-')) + for (const [name, contents] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(cwd, name)), { recursive: true }) + fs.writeFileSync(path.join(cwd, name), contents) + } + + return cwd +} + +/** + * Each request as `METHOD path`, for asserting what the action asked the API. + * + * @param {object[]} requests The requests recordingFetch recorded. + * + * @returns {string[]} One line per request. + */ +function requested(requests) { + // The upload URL comes from the `{?name,label}` template, which leaves a trailing `&` for the + // empty label. + return requests.map((request) => `${request.method} ${request.path.replace(/^https:\/\/uploads\.github\.com/, 'uploads:').replace(/&$/, '')}`) +} + +describe('release-publish', () => { + describe('validateVersion', () => { + it('closes its message with what was not done', () => { + assert.strictEqual(validateVersion(' 4.17.0 '), '4.17.0') + assert.throws(() => validateVersion('4.17.0-beta.1'), /not a version this action can release.*Nothing was published/s) + assert.throws(() => validateVersion(''), /No version was given.*Nothing was published/s) + }) + }) + + describe('readTarget', () => { + it('takes a hash or a branch name, never a ref or nothing', () => { + assert.strictEqual(readTarget(` ${SHA} `), SHA) + assert.strictEqual(readTarget('main'), 'main') + assert.throws(() => readTarget(''), /No target was given.*base-sha/s) + assert.throws(() => readTarget('refs/heads/main'), /not a ref/) + }) + }) + + describe('resolveTarget', () => { + it('returns a hash as it is, without asking the API', async () => { + const { github, requests } = await octokitFor([]) + + assert.strictEqual(await resolveTarget({ github, owner: 'stellarwp', repo: 'plugin', target: SHA }), SHA) + assert.deepStrictEqual(requests, []) + }) + + it('resolves a branch name to its tip', async () => { + const { github } = await octokitFor([{ method: 'GET', path: API.branch, body: { commit: { sha: SHA } } }]) + + assert.strictEqual(await resolveTarget({ github, owner: 'stellarwp', repo: 'plugin', target: 'main' }), SHA) + }) + + it('refuses a name that is neither a hash nor a branch', async () => { + const { github } = await octokitFor([]) + + await assert.rejects( + resolveTarget({ github, owner: 'stellarwp', repo: 'plugin', target: 'nope' }), + /'nope' is not a commit hash, and the repository has no branch by that name/ + ) + }) + }) + + describe('readTitle', () => { + it('is the version unless a title was given', () => { + assert.strictEqual(readTitle('', '4.17.0'), '4.17.0') + assert.strictEqual(readTitle(' Maintenance Release 4.17.0 ', '4.17.0'), 'Maintenance Release 4.17.0') + }) + }) + + describe('readNotesFile', () => { + it('reads the file from the checkout, and names it when it is missing', () => { + const cwd = checkoutWith({ 'notes.md': '* Fixed a thing\n' }) + + assert.strictEqual(readNotesFile('', cwd), null) + assert.strictEqual(readNotesFile('notes.md', cwd), '* Fixed a thing\n') + assert.throws(() => readNotesFile('missing.md', cwd), /notes file missing\.md could not be read.*Nothing was published/s) + }) + }) + + describe('readFiles', () => { + it('lists one asset per line, each checked to be a file in the checkout', () => { + const cwd = checkoutWith({ 'build/plugin.zip': 'zip', 'readme.txt': 'readme' }) + + assert.deepStrictEqual(readFiles('', cwd), []) + assert.deepStrictEqual( + readFiles('\nbuild/plugin.zip\n readme.txt \n', cwd).map(({ name, size }) => ({ name, size })), + [ + { name: 'plugin.zip', size: 3 }, + { name: 'readme.txt', size: 6 }, + ] + ) + assert.throws(() => readFiles('build/plugin.zip\nbuild', cwd), /^Error: build, listed in files, is not a file/) + assert.throws(() => readFiles('missing.zip', cwd), /missing\.zip, listed in files, is not a file.*Nothing was published/s) + }) + }) + + describe('makeLatest', () => { + it('keeps "Latest" on a newer release when an older line gets a hotfix', () => { + assert.strictEqual(makeLatest('4.17.0', null), 'true') + assert.strictEqual(makeLatest('4.17.0', '4.16.0'), 'true') + assert.strictEqual(makeLatest('4.17.0', '4.17.0.1'), 'false') + assert.strictEqual(makeLatest('4.16.1', '4.17.0'), 'false') + // A latest whose tag is not a plain version is not compared. + assert.strictEqual(makeLatest('4.16.1', 'v4.17.0'), 'true') + }) + }) + + describe('runPublish', () => { + it('publishes the release at the commit, titled with the version, with generated notes', async (t) => { + const { github, requests } = await octokitFor([...FRESH, { method: 'POST', path: API.releases, status: 201, body: { id: 77, html_url: RELEASE_URL } }]) + const { core, outputs, summary } = await coreWithOutputs(t) + + await runPublish({ github, core, env: publishEnv(), cwd: checkoutWith() }) + + assert.deepStrictEqual(requested(requests), [ + `GET ${API.releaseByTag}`, + `GET ${API.tag}`, + `GET ${API.latest}`, + `POST ${API.notes}`, + `POST ${API.releases}`, + ]) + assert.deepStrictEqual(requests[3].body, { tag_name: '4.17.0', target_commitish: SHA }) + assert.deepStrictEqual(requests[4].body, { + tag_name: '4.17.0', + target_commitish: SHA, + name: '4.17.0', + body: '## What changed\n* Fix (#12)', + make_latest: 'true', + }) + assert.deepStrictEqual(outputs(), { published: 'true', 'release-url': RELEASE_URL, sha: SHA }) + assert.match(summary(), /Published release 4\.17\.0 at `0123456`/) + }) + + it('publishes at the tip of a branch given as target, with the title and the notes file', async (t) => { + const cwd = checkoutWith({ 'notes.md': '= 4.17.0 =\n* Fixed a thing\n' }) + const { github, requests } = await octokitFor([ + { method: 'GET', path: API.branch, body: { commit: { sha: SHA } } }, + { method: 'GET', path: API.latest, body: { tag_name: '4.16.0' } }, + { method: 'POST', path: API.releases, status: 201, body: { id: 77, html_url: RELEASE_URL } }, + ]) + const { core } = await coreWithOutputs(t) + + await runPublish({ + github, + core, + env: publishEnv({ INPUT_TARGET: 'main', INPUT_TITLE: 'Maintenance Release 4.17.0', INPUT_NOTES_FILE: 'notes.md' }), + cwd, + }) + + // No generated notes were asked for: the file is the body. + assert.ok(!requested(requests).includes(`POST ${API.notes}`)) + assert.deepStrictEqual(requests.at(-1).body, { + tag_name: '4.17.0', + target_commitish: SHA, + name: 'Maintenance Release 4.17.0', + body: '= 4.17.0 =\n* Fixed a thing\n', + make_latest: 'true', + }) + }) + + it('does not take "Latest" from a newer release when it is a hotfix on an older line', async (t) => { + const { github, requests } = await octokitFor([ + { method: 'POST', path: API.notes, body: { body: 'notes' } }, + { method: 'GET', path: API.latest, body: { tag_name: '4.18.0' } }, + { method: 'POST', path: API.releases, status: 201, body: { id: 77, html_url: RELEASE_URL } }, + ]) + const { core, logged } = await coreWithOutputs(t) + + await runPublish({ github, core, env: publishEnv(), cwd: checkoutWith() }) + + assert.strictEqual(requests.at(-1).body.make_latest, 'false') + assert.match(logged(), /below the current latest release/) + }) + + it('takes "Latest" when the repository has no release yet', async (t) => { + const { github, requests } = await octokitFor([ + { method: 'POST', path: API.notes, body: { body: 'notes' } }, + { method: 'POST', path: API.releases, status: 201, body: { id: 77, html_url: RELEASE_URL } }, + ]) + const { core } = await coreWithOutputs(t) + + await runPublish({ github, core, env: publishEnv(), cwd: checkoutWith() }) + + assert.strictEqual(requests.at(-1).body.make_latest, 'true') + }) + + it('uploads the files to a draft and publishes it last, so the release is never public without them', async (t) => { + const cwd = checkoutWith({ 'build/plugin.zip': 'PK zip bytes', 'plugin.pot': 'msgid' }) + const { github, requests } = await octokitFor([ + ...FRESH, + { method: 'POST', path: API.releases, status: 201, body: { id: 77, html_url: RELEASE_URL, upload_url: 'https://uploads.github.com/repos/stellarwp/plugin/releases/77/assets{?name,label}' } }, + { method: 'POST', path: API.assets, status: 201, body: { id: 1 } }, + { method: 'PATCH', path: API.release, body: { id: 77, html_url: RELEASE_URL } }, + ]) + const { core, outputs, summary, logged } = await coreWithOutputs(t) + + await runPublish({ github, core, env: publishEnv({ INPUT_FILES: 'build/plugin.zip\nplugin.pot\n' }), cwd }) + + assert.deepStrictEqual(requested(requests), [ + `GET ${API.releaseByTag}`, + `GET ${API.tag}`, + `GET ${API.latest}`, + `POST ${API.notes}`, + `POST ${API.releases}`, + 'POST uploads:/repos/stellarwp/plugin/releases/77/assets?name=plugin.zip', + 'POST uploads:/repos/stellarwp/plugin/releases/77/assets?name=plugin.pot', + `PATCH ${API.release}`, + ]) + // Created as a draft, with no make_latest yet: that is decided when it is published. + assert.deepStrictEqual(requests[4].body, { + tag_name: '4.17.0', + target_commitish: SHA, + name: '4.17.0', + body: '## What changed\n* Fix (#12)', + draft: true, + }) + assert.strictEqual(String(requests[5].body), 'PK zip bytes') + assert.deepStrictEqual(requests[7].body, { draft: false, make_latest: 'true' }) + assert.deepStrictEqual(outputs(), { published: 'true', 'release-url': RELEASE_URL, sha: SHA }) + assert.match(logged(), /Attached plugin\.zip.*Attached plugin\.pot/s) + assert.match(summary(), /with `plugin\.zip`, `plugin\.pot`/) + }) + + it('deletes the draft when an upload fails, leaving no release and no tag', async (t) => { + const cwd = checkoutWith({ 'build/plugin.zip': 'zip' }) + const { github, requests } = await octokitFor([ + ...FRESH, + { method: 'POST', path: API.releases, status: 201, body: { id: 77, html_url: RELEASE_URL } }, + { method: 'POST', path: API.assets, status: 502, body: { message: 'bad gateway' } }, + { method: 'DELETE', path: API.release, status: 204, body: {} }, + ]) + const { core, outputs } = await coreWithOutputs(t) + + await assert.rejects( + runPublish({ github, core, env: publishEnv({ INPUT_FILES: 'build/plugin.zip' }), cwd }), + /Uploading plugin\.zip to the release failed \(HTTP 502\).*draft was deleted and no tag was made.*Nothing was published/s + ) + + assert.deepStrictEqual(requested(requests).slice(-2), ['POST uploads:/repos/stellarwp/plugin/releases/77/assets?name=plugin.zip', `DELETE ${API.release}`]) + assert.deepStrictEqual(outputs(), {}) + }) + + it('refuses a missing file before creating anything', async (t) => { + const { github, requests } = await octokitFor(FRESH) + const { core } = await coreWithOutputs(t) + + await assert.rejects( + runPublish({ github, core, env: publishEnv({ INPUT_FILES: 'build/plugin.zip' }), cwd: checkoutWith() }), + /build\/plugin\.zip, listed in files, is not a file/ + ) + assert.deepStrictEqual(requests, []) + }) + + it('skips a release that already exists, so a retry succeeds', async (t) => { + const { github, requests } = await octokitFor([{ method: 'GET', path: API.releaseByTag, body: { html_url: RELEASE_URL } }]) + const { core, outputs, logged } = await coreWithOutputs(t) + + await runPublish({ github, core, env: publishEnv(), cwd: checkoutWith() }) + + assert.deepStrictEqual(requested(requests), [`GET ${API.releaseByTag}`]) + assert.deepStrictEqual(outputs(), { published: 'skipped', 'release-url': RELEASE_URL, sha: '' }) + assert.match(logged(), /4\.17\.0 is already published/) + }) + + it('refuses a tag that exists without a release, rather than releasing whatever it points at', async (t) => { + const { github, requests } = await octokitFor([{ method: 'GET', path: API.tag, body: { ref: 'refs/tags/4.17.0', object: { sha: 'abc' } } }]) + const { core } = await coreWithOutputs(t) + + await assert.rejects(runPublish({ github, core, env: publishEnv(), cwd: checkoutWith() }), /tag 4\.17\.0 already exists, but there is no published release/) + assert.deepStrictEqual(requested(requests), [`GET ${API.releaseByTag}`, `GET ${API.tag}`]) + }) + + it('fails on an API error reading the release rather than publishing twice', async (t) => { + const { github } = await octokitFor([{ method: 'GET', path: API.releaseByTag, status: 500, body: { message: 'boom' } }]) + const { core } = await coreWithOutputs(t) + + await assert.rejects(runPublish({ github, core, env: publishEnv(), cwd: checkoutWith() }), /Reading the release for 4\.17\.0 failed.*500.*Nothing was published/s) + }) + + it('refuses a wrong version or an empty target before asking the API anything', async (t) => { + const { github, requests } = await octokitFor([]) + const { core } = await coreWithOutputs(t) + + await assert.rejects(runPublish({ github, core, env: publishEnv({ INPUT_VERSION: 'v4.17.0' }), cwd: checkoutWith() }), /not a version this action can release/) + await assert.rejects(runPublish({ github, core, env: publishEnv({ INPUT_TARGET: '' }), cwd: checkoutWith() }), /No target was given/) + await assert.rejects(runPublish({ github, core, env: publishEnv({ GITHUB_REPOSITORY: '' }), cwd: checkoutWith() }), /GITHUB_REPOSITORY is not set/) + assert.deepStrictEqual(requests, []) + }) + }) +}) diff --git a/tests/support/actions.js b/tests/support/actions.js index fd0fb50..caab724 100644 --- a/tests/support/actions.js +++ b/tests/support/actions.js @@ -44,6 +44,28 @@ function jsonResponse({ status = 200, body = {}, headers = {} }) { }) } +/** + * The body of a recorded request: JSON parsed, anything else (an asset upload) as it was sent. + * + * @param {*} body What the request carried. + * + * @returns {*} The parsed JSON, the raw body, or undefined for none. + */ +function parseBody(body) { + if (body === undefined || body === null) { + return undefined + } + if (typeof body === 'string') { + try { + return JSON.parse(body) + } catch { + return body + } + } + + return body +} + /** * A `fetch` that answers from a list of rules and records every request it was given. * @@ -75,7 +97,7 @@ function recordingFetch(rules = []) { method, url: href, path: requestPath, - body: options.body ? JSON.parse(options.body) : undefined, + body: parseBody(options.body), }) for (const [index, rule] of rules.entries()) {