Repository navigation
Add a release-merge action that merges the release into the base and develop, then publishes it #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ef939b9
84d5961
1027c4a
8bce125
a74b151
f64b62d
23b6a71
2e083ba
2e84685
4779f4c
d2cf03a
b066fe6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| # release-merge | ||
|
|
||
| Merges a release and publishes it on GitHub: the release branch into the base branch, the base | ||
| branch back into develop, then the GitHub release for the version. **The base branch is `main`, or | ||
| `master` where that is the repository's name for it**, set with `base-branch`: | ||
|
|
||
| ```yaml | ||
| - uses: actions/checkout@v6 | ||
| with: | ||
| persist-credentials: false | ||
|
|
||
| - uses: stellarwp/plugin-toolbox/.github/actions/release-merge@v1 | ||
| with: | ||
| branch: ${{ inputs.branch }} | ||
| version: ${{ inputs.version }} | ||
| ``` | ||
| It is the part of a deploy that is the same for every plugin repository on a base branch plus | ||
| `develop` model. Building the plugin, uploading it anywhere and announcing it are the caller's | ||
| steps, after the action, on the checkout it leaves on the merged base branch. | ||
| [`templates/workflows/release-merge.yml`](../../../templates/workflows/release-merge.yml) is a | ||
| complete workflow around it, ready to copy into a plugin repository. | ||
|
|
||
| ## What it does | ||
|
|
||
| In this order, stopping at the first failure: | ||
|
|
||
| 1. Checks the inputs, and the version against the repository's tags. See | ||
| [Versions it refuses](#versions-it-refuses). | ||
| 2. Fetches the release branch, the base branch and develop from `origin`, with the full history | ||
| when the checkout is shallow. Then it checks that every version file the branch's `.puprc` lists | ||
| holds the version and, when the version is already tagged, that the tag contains the branch: a | ||
| retry of this release goes on, a tag made from another commit is refused. | ||
| 3. Merges the release branch into the base branch as a merge commit, `Merge release/… into main` | ||
| (or `into master`), and pushes it. | ||
| 4. Merges the **base branch** into develop as a merge commit, `Merge main back into develop | ||
| (release/…)`, and pushes it. The base branch rather than the release branch, so develop | ||
| descends from the base and consecutive releases bring one version bump each instead of | ||
| divergent copies of the same generated lines. | ||
| 5. Publishes the GitHub release: tag and title are the version, target is the base branch, the notes | ||
| are the ones GitHub generates from the pull requests since the previous release. Skipped with | ||
| `publish: false`. | ||
|
|
||
| The checkout is left on the base branch at the merged commit, clean. The merge commits are made by | ||
| `github-actions[bot]`; the pushes and the release are made by the identity of `token`, also | ||
| `github-actions[bot]` for the default token. | ||
|
|
||
| ## Running it again | ||
|
|
||
| Every step is safe to run again after a late failure, which is what a retry from a release tool | ||
| does: | ||
|
|
||
| - A branch that already holds the other merges as "Already up to date" and pushes nothing. | ||
| - A release that already exists is skipped, and its URL is still reported in `release-url`. It | ||
| 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). | ||
| - A conflict resolved by hand on the branch, then pushed, is found as an already-merged branch on | ||
| the next run, which goes on to the step that had not run. | ||
|
|
||
| So a run that failed publishing, or after merging the base, is rerun as it is; nothing has to be | ||
| undone first. | ||
|
|
||
| ## When a merge conflicts | ||
|
|
||
| The conflicted merge is abandoned (`git merge --abort`), nothing is pushed for that branch, and the | ||
| step fails with the branch named. The branches already pushed stay pushed: a conflict merging the | ||
| base into develop leaves the release on the base. Resolve the conflict on the branch by hand, push, | ||
| and run the step again. | ||
|
|
||
| The log carries this line, on one line: | ||
|
|
||
| ``` | ||
| MERGE_CONFLICT: merging main into develop conflicted. Resolve it on develop by hand and push, | ||
| then run this step again. Nothing was pushed to develop. | ||
| ``` | ||
|
|
||
| and the `conflict` output names the branch. The [release | ||
| dashboard](https://github.com/stellarwp/nexcess-one) classifies the run from that line: it reads the | ||
| marker `MERGE_CONFLICT` at the start of an output line of the job log, every line of which the | ||
| runner prefixes with a timestamp. The marker is printed from the script, not written in the | ||
| workflow, so it is not in the step's echoed inputs and only appears when a merge did conflict. The | ||
| two lines git itself prints, `CONFLICT (…)` and `Automatic merge failed`, are in the log as well. | ||
|
|
||
| ## Versions it refuses | ||
|
|
||
| - **Anything but three or four numeric parts**, such as `4.17.0` or `4.17.0.1`. A pre-release such | ||
| as `4.17.0-beta.1` and a leading `v` are refused: the version becomes the tag and the title of the | ||
| published release, and a beta or an RC is not released this way. The release dashboard validates | ||
| versions with the same rule, so a version it dispatches has passed it already; release-prep | ||
| does under the stellarwp changelog versioning. | ||
| - **A version the branch does not hold.** The release branch's `.puprc` lists the files that carry the | ||
| version under `paths.versions`, with the regex that finds it in each, the same entries release-prep | ||
| bumps. The action reads every one of them on the branch and refuses when any holds a different | ||
| version, naming the file: the version becomes the tag, so it has to be the one the branch was | ||
| prepared with. A branch without a `.puprc`, or with a file where the regex finds nothing, is | ||
| refused too, since the version it holds cannot be read. | ||
| - **An empty version.** A tool that dispatches the workflow for a release with no version set sends | ||
| an empty one. When the workflow declares `version` as required, GitHub refuses the dispatch | ||
| itself; when it is optional, the action fails the run before merging. | ||
| - **A version whose tag exists without a published release.** Publishing would release whatever | ||
| commit the tag points at. Delete the tag or release another version. | ||
| - **A version already released from another commit.** When the tag exists with a published release | ||
| and does not contain the release branch, the version went out before this branch, and the branch | ||
| was never bumped past it: a branch cut from `main` right after a release still carries that | ||
| release's version. Merging it would put a second set of changes under a version that is already | ||
| out, so the action refuses, with the branches untouched. Give the release a new version and | ||
| prepare the branch again. A tag that does contain the branch is a retry of this release, and the | ||
| action goes on. | ||
|
|
||
| The branches are checked too: each has to be a name git accepts, the base and develop have to be | ||
| two different branches, and the release branch cannot be either of them. | ||
|
|
||
| ## Inputs | ||
|
|
||
| | Input | Required | Default | What it is | | ||
| |---|---|---|---| | ||
| | `branch` | yes | | The release branch to merge, e.g. `release/M26.jigglypuff`. A branch name, fetched from `origin`. | | ||
| | `version` | yes | | The version being released: three or four numeric parts. | | ||
| | `base-branch` | no | `main` | The branch the release is merged into: `main`, or `master` where that is the repository's name for it. | | ||
| | `develop-branch` | no | `develop` | The branch the base is merged back into. | | ||
| | `publish` | no | `true` | `false` to do the two merges without the GitHub release. | | ||
| | `token` | no | `github.token` | The token for the pushes and the release. Needs `contents: write`. See [The token](#the-token). | | ||
|
|
||
| ## Outputs | ||
|
|
||
| | Output | What it is | | ||
| |---|---| | ||
| | `version` | The version released. | | ||
| | `branch` | The release branch merged. | | ||
| | `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. | | ||
| | `published` | `true` when the release was created, `skipped` when it already existed, `false` when `publish` was false. | | ||
| | `release-url` | The release page, empty when `publish` was false. | | ||
|
|
||
| The merge step also sets `base-branch` and `develop-branch` as step outputs, for the publish step; | ||
| the action does not expose them. | ||
|
|
||
| ## One deploy at a time | ||
|
|
||
| Two releases prepared with the same version, deployed at the same time, would merge side by side: | ||
| the second's changes would land on the base under a version the first had just published, and its | ||
| publish would be skipped as already done. The action cannot stop that on its own, since both runs | ||
| start before either has a tag to find. The caller does, with a `concurrency` group shared by every | ||
| deploy of the repository, as the template sets: | ||
|
|
||
| ```yaml | ||
| concurrency: | ||
| group: ${{ github.workflow }} | ||
| cancel-in-progress: false | ||
| ``` | ||
|
|
||
| The second run then waits for the first, finds the version tagged from another branch, and is | ||
| refused before merging. A repository that also deploys from another workflow gives it the same | ||
| group. GitHub keeps one run waiting per group: a third deploy started while one is already waiting | ||
| cancels the waiting one, which has to be started again. | ||
|
|
||
| ## The token | ||
|
|
||
| The job needs `contents: write`, and the token is used for the fetch, the two pushes and the | ||
| release. It is handed to git through the environment of those commands only (`GIT_CONFIG_*`, the | ||
| `http.<server>/.extraheader` header actions/checkout would store), so it is never in the checkout's | ||
| `.git/config` and never on a command line in the log. Use `persist-credentials: false` on the | ||
| checkout; it matters when later steps of the same job run third-party actions. | ||
|
|
||
| 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 the release is the | ||
| one that deploys it, so a repository that also has an `on: release` deploy does | ||
| not deploy it twice. A release that should trigger other workflows needs a token of its own. | ||
|
|
||
| ## The checkout | ||
|
|
||
| The action runs in the caller's checkout, which can be on any branch and at any depth: the three | ||
| branches are fetched explicitly, and a shallow checkout is unshallowed first. Run it on a clean | ||
| checkout; it switches branches, and uncommitted changes would travel with it or stop it. | ||
|
|
||
| The checkout's `origin` has to be the repository the run is on: `GITHUB_REPOSITORY` is where the | ||
| tags and the release are read and written. | ||
|
|
||
| ## How it runs | ||
|
|
||
| Two [actions/github-script](https://github.com/actions/github-script) steps run | ||
| [`release-merge.js`](./release-merge.js): one for the checks and the merges, one for the release. | ||
| The inputs reach the script through environment variables, never interpolated into it, so a crafted | ||
| value is data rather than part of the program. The merges are git commands in the checkout; the tag | ||
| and release reads and the publish go through github-script's Octokit. | ||
|
|
||
| The tests, `node --test` from the repository root, run the merges with real git against a bare | ||
| repository on disk and the API calls against a recorded client. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| # Merges a release: the release branch into the base branch (`main`, or `master` where that is the | ||
| # name), the base branch back into develop, then publishes the GitHub release for the version. The | ||
| # part of a deploy that is the same for every plugin; what happens after the merge (building, | ||
| # wordpress.org, announcements) is the caller's. | ||
| # | ||
| # The checks, the merges and the publish live in release-merge.js, run by actions/github-script, so | ||
| # there is no dependency to install and nothing to build. The merges are git in the caller's | ||
| # checkout; the tag and release reads and the publish go through the GitHub API. | ||
| name: Release merge | ||
| description: Merges the release branch into the base and the base back into develop, then publishes the GitHub release | ||
|
|
||
| inputs: | ||
| branch: | ||
| description: >- | ||
| The release branch to merge, e.g. `release/M26.jigglypuff`. A branch name, not any git ref. It is | ||
| fetched from `origin`; the caller's checkout can be on any branch and at any depth. | ||
| required: true | ||
| version: | ||
| description: >- | ||
| The version being released: three or four numeric parts, such as 4.17.0 or 4.17.0.1, the rule | ||
| the release dashboard applies. It becomes the tag and the title of the GitHub release, so it has | ||
| to be the version the release branch holds: every file the branch's .puprc lists under | ||
| paths.versions is read, and a different version in any of them is refused. A pre-release such as | ||
| 4.17.0-beta.1 and an empty value are refused before anything is merged, and so is a version | ||
| whose tag already exists, unless that tag's release was published from this branch, which makes | ||
| the run a retry. | ||
| required: true | ||
| base-branch: | ||
| description: 'The branch the release is merged into: `main`, or `master` where that is the name.' | ||
| required: false | ||
| default: 'main' | ||
| develop-branch: | ||
| description: 'The branch the base is merged back into after the release.' | ||
| required: false | ||
| default: 'develop' | ||
| publish: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could the publish step take a few optional inputs: |
||
| description: >- | ||
| Whether to publish the GitHub release once both merges are pushed: `true` or `false`. Set it to | ||
| false to use the two merges on their own. | ||
| required: false | ||
| default: 'true' | ||
| token: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One token does both the pushes and the release. On repos with a tag ruleset, |
||
| description: >- | ||
| Token the pushes and the release are made with. It needs `contents: write` on the repository, so | ||
| the job needs that permission too. It is handed to git through the environment of the fetch and | ||
| the pushes only, never written to the checkout's config, so the checkout can use | ||
| `persist-credentials: false`. A release published with the default token triggers no `release` | ||
| workflow: the run that merged the release is the one that deploys it. | ||
| required: false | ||
| default: ${{ github.token }} | ||
|
|
||
| outputs: | ||
| version: | ||
| description: 'The version released.' | ||
| value: ${{ steps.merge.outputs.version }} | ||
| branch: | ||
| description: 'The release branch merged.' | ||
| value: ${{ steps.merge.outputs.branch }} | ||
| conflict: | ||
| description: >- | ||
| Which branch a merge conflicted on, `main` or `develop` for the defaults, or empty when both | ||
| merges went through. A conflict fails the step, so a caller reads this with `continue-on-error` | ||
| or in a later `if: failure()` step. | ||
| value: ${{ steps.merge.outputs.conflict }} | ||
| published: | ||
| description: '`true` when the release was created, `skipped` when it already existed, `false` when publish was false.' | ||
| value: ${{ steps.publish.outputs.published }} | ||
| release-url: | ||
| description: 'The GitHub release page, empty when publish was false.' | ||
| value: ${{ steps.publish.outputs.release-url }} | ||
|
|
||
| runs: | ||
| using: 'composite' | ||
| steps: | ||
| - name: Merge the release into the base and the base back into develop | ||
| id: merge | ||
| uses: actions/github-script@v9 | ||
| env: | ||
| # github-script's require() resolves against the workspace of the repository being built, | ||
| # not this action's directory, so the path has to be passed in. github.action_path is the | ||
| # documented expression for it. | ||
| ACTION_PATH: ${{ github.action_path }} | ||
| # Read from process.env rather than interpolated into the script, so an input is data and | ||
| # never part of the program. | ||
| INPUT_BRANCH: ${{ inputs.branch }} | ||
| INPUT_VERSION: ${{ inputs.version }} | ||
| INPUT_BASE_BRANCH: ${{ inputs.base-branch }} | ||
| INPUT_DEVELOP_BRANCH: ${{ inputs.develop-branch }} | ||
| # Checked here too, so a wrong value fails before anything is merged. | ||
| INPUT_PUBLISH: ${{ inputs.publish }} | ||
| INPUT_TOKEN: ${{ inputs.token }} | ||
| with: | ||
| # The tag and release reads go through github-script's own client, built from this token. | ||
| github-token: ${{ inputs.token }} | ||
| script: | | ||
| const { runMerge } = require(`${process.env.ACTION_PATH}/release-merge.js`) | ||
| await runMerge({ github, core, exec, env: process.env }) | ||
|
|
||
| - name: Publish the GitHub release | ||
| id: publish | ||
| uses: actions/github-script@v9 | ||
| env: | ||
| ACTION_PATH: ${{ github.action_path }} | ||
| # The values the merge step validated, not the raw inputs. base-branch and develop-branch | ||
| # are outputs of that step for this hand-over only; the action exposes neither. | ||
| INPUT_VERSION: ${{ steps.merge.outputs.version }} | ||
| INPUT_BASE_BRANCH: ${{ steps.merge.outputs.base-branch }} | ||
| INPUT_PUBLISH: ${{ inputs.publish }} | ||
| with: | ||
| github-token: ${{ inputs.token }} | ||
| script: | | ||
| const { runPublish } = require(`${process.env.ACTION_PATH}/release-merge.js`) | ||
| await runPublish({ github, core, env: process.env }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Small one: it might be worth saying that a PAT or GitHub App token, which some repos will need because of tag rulesets, does trigger
on: releaseworkflows. Then the "doesn't deploy twice" guarantee doesn't hold, and the concurrency note becomes more important.