Repository navigation
Add release-merge: the release branch into the base branch, then the base branch into develop #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
Merged
Merged
Changes from all commits
Commits
Show all changes
25 commits
Select commit
Hold shift + click to select a range
80ba03b
Share the version pattern and the real-exec test helper
glaubersilva 373d021
Add the release-merge action
glaubersilva c5d9dad
Document the release-merge action and add a caller template
glaubersilva 7bcb01e
Refuse a published version whose tag does not contain the release branch
glaubersilva 8a8c275
Serialize the deploys of a repository in the caller template
glaubersilva d2df7cd
Plain wording in the release-merge docs and the shared version helper
glaubersilva 97c9ebf
Share the repository, tag and command helpers between the actions
glaubersilva fb57f6b
Rewrap the release-merge template's header comment
glaubersilva 358ff9f
Refuse a version the release branch does not hold
glaubersilva 65b2dcf
Rewrap the release-merge manifest's header comment
glaubersilva 45c7cdb
Take the release branch whole in the caller template
glaubersilva 54da660
Check the publish input before merging and name the file behind a reg…
glaubersilva 61d800e
Publish the release at the commit the merge left on the base, not at …
glaubersilva dadfacc
Let develop-branch be empty, for a repository that has no develop branch
glaubersilva c959753
Say what the repository has to allow the token, and that the environm…
glaubersilva ca9f3ea
Say that an empty develop-branch is written in the caller, not passed…
glaubersilva 6f3f198
Leave publishing the release to the caller: release-merge only merges
glaubersilva 24b94c4
Fix the sentence on what the rules have to allow the token
glaubersilva afa7337
Keep every waiting deploy in the concurrency queue
glaubersilva 2203644
Drop the production input from the merge template: the dashboard no l…
glaubersilva 6c4b554
Back to the default concurrency queue: actionlint does not know queue…
glaubersilva 6e64540
Drop the merge-only caller template: a deploy workflow merges and pub…
glaubersilva b916c25
Merge through the GitHub API instead of git in the caller's checkout
glaubersilva 1b4d3a4
Merge the tip that was checked, not the release branch by name
glaubersilva eeb3d77
Refuse a release branch that moved after the run was started
glaubersilva File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,203 @@ | ||
| # release-merge | ||
|
|
||
| Merges a release: the release branch into the base branch, then the base branch back into develop, | ||
| through the GitHub API. **The base branch is `main`, or `master` where that is the repository's name | ||
| for it**, set with `base-branch`: | ||
|
|
||
| ```yaml | ||
| - uses: stellarwp/plugin-toolbox/.github/actions/release-merge@v1 | ||
| id: merge | ||
| with: | ||
| branch: ${{ inputs.branch }} | ||
| version: ${{ inputs.version }} | ||
|
|
||
| # The merged commit, to build the release from: | ||
| - uses: actions/checkout@v6 | ||
| with: | ||
| ref: ${{ steps.merge.outputs.base-sha }} | ||
|
|
||
| # Build the plugin here, then publish the release at ${{ steps.merge.outputs.base-sha }}. | ||
| ``` | ||
|
|
||
| Publishing the GitHub release is not this action's: the caller does it after its build, at the | ||
| commit this action leaves on the base branch (`base-sha`), so the release can carry the build and is | ||
| published only once it exists. | ||
|
|
||
| It is the part of a deploy that is the same for every plugin repository, on a base branch plus | ||
| `develop` model or on a base branch alone (`develop-branch: ''` skips the merge back). Building the | ||
| plugin, uploading it anywhere and announcing it are the caller's steps, after the action, on a | ||
| checkout of `base-sha`. | ||
|
|
||
| ## What it does | ||
|
|
||
| In this order, stopping at the first failure: | ||
|
|
||
| 1. Checks the inputs, and the version against the repository's tags. See | ||
| [Versions it refuses](#versions-it-refuses). | ||
| 2. Checks that the release branch, the base branch and develop exist, then that every version file | ||
| the branch's `.puprc` lists holds the version and, when the version is already tagged, that the | ||
| tag contains the branch: a retry of this release goes on, a tag made from another commit is | ||
| refused. | ||
| 3. Merges the release branch into the base branch as a merge commit, `Merge release/… into main` | ||
| (or `into master`). | ||
| 4. Merges the **merged commit** into develop as a merge commit, `Merge main back into develop | ||
| (release/…)`. The base branch rather than the release branch, so develop descends from the | ||
| base and consecutive releases bring one version bump each instead of divergent copies of the | ||
| same generated lines. Skipped when `develop-branch` is empty, for a repository that has no | ||
| develop branch. | ||
|
|
||
| The merge commit on the base branch is the `base-sha` output: what the caller checks out, builds | ||
| from and tags. GitHub makes both merge commits under the identity of `token`, `github-actions[bot]` | ||
| for the default token, and signs them. The API never fast-forwards, so each release is one merge | ||
| commit in the history. | ||
|
|
||
| The action reads and writes no checkout: it needs none before it, and leaves whatever checkout the | ||
| job has as it was. Check out `base-sha` after it for the steps that build the release. | ||
|
|
||
| ## Running it again | ||
|
|
||
| Every step is safe to run again after a late failure, which is what a retry from a release tool | ||
| does: | ||
|
|
||
| - A branch that already holds the other merges as nothing to merge: GitHub answers that the base | ||
| already contains the head, and nothing is written. | ||
| - A version already published counts as a retry only when its tag contains the release branch, | ||
| that is, when it was published from this branch. See [Versions it refuses](#versions-it-refuses). | ||
| The caller's publish then has nothing to do. | ||
| - A conflict resolved by hand on the branch, then pushed, is found as an already-merged branch on | ||
| the next run, which goes on to the step that had not run. | ||
|
|
||
| So a run that failed after merging the base, or in a later step of the caller, is rerun as it is; | ||
| nothing has to be undone first. | ||
|
|
||
| ## When a merge conflicts | ||
|
|
||
| GitHub refuses the merge, nothing is written for that branch, and the step fails with the branch | ||
| named. The branches already merged stay merged: a conflict merging the base into develop leaves the | ||
| release on the base. Resolve the conflict on the branch by hand, push, and run the step again. The | ||
| API does not say which files conflict; the merge done by hand shows them. | ||
|
|
||
| The log carries this line, on one line: | ||
|
|
||
| ``` | ||
| MERGE_CONFLICT: merging main into develop conflicted. Resolve it on develop by hand and push, | ||
| then run this step again. Nothing was merged into develop. | ||
| ``` | ||
|
|
||
| and the `conflict` output names the branch. The [release | ||
| dashboard](https://github.com/stellarwp/nexcess-one) classifies the run from that line: it reads the | ||
| marker `MERGE_CONFLICT` at the start of an output line of the job log, every line of which the | ||
| runner prefixes with a timestamp. The marker is printed from the script, not written in the | ||
| workflow, so it is not in the step's echoed inputs and only appears when a merge did conflict. | ||
|
|
||
| A merge GitHub refuses for another reason (the base branch moved while the merge was being made) | ||
| fails the step without the marker; running the step again is enough. | ||
|
|
||
| ## Versions it refuses | ||
|
|
||
| - **Anything but three or four numeric parts**, such as `4.17.0` or `4.17.0.1`. A pre-release such | ||
| as `4.17.0-beta.1` and a leading `v` are refused: the version becomes the tag and the title of the | ||
| published release, and a beta or an RC is not released this way. The release dashboard validates | ||
| versions with the same rule, so a version it dispatches has passed it already; release-prep | ||
| does under the stellarwp changelog versioning. | ||
| - **A version the branch does not hold.** The release branch's `.puprc` lists the files that carry the | ||
| version under `paths.versions`, with the regex that finds it in each, the same entries release-prep | ||
| bumps. The action reads every one of them on the branch and refuses when any holds a different | ||
| version, naming the file: the version becomes the tag, so it has to be the one the branch was | ||
| prepared with. A branch without a `.puprc`, or with a file where the regex finds nothing, is | ||
| refused too, since the version it holds cannot be read. | ||
| - **An empty version.** A tool that dispatches the workflow for a release with no version set sends | ||
| an empty one. When the workflow declares `version` as required, GitHub refuses the dispatch | ||
| itself; when it is optional, the action fails the run before merging. | ||
| - **A version whose tag exists without a published release.** Publishing would release whatever | ||
| commit the tag points at. Delete the tag or release another version. | ||
| - **A version already released from another commit.** When the tag exists with a published release | ||
| and does not contain the release branch, the version went out before this branch, and the branch | ||
| was never bumped past it: a branch cut from `main` right after a release still carries that | ||
| release's version. Merging it would put a second set of changes under a version that is already | ||
| out, so the action refuses, with the branches untouched. Give the release a new version and | ||
| prepare the branch again. A tag that does contain the branch is a retry of this release, and the | ||
| action goes on. | ||
|
|
||
| The branches are checked too: each has to exist in the repository, the base and develop have to be | ||
| two different branches (when there is a develop), and the release branch cannot be either of them. | ||
|
|
||
| The release branch's tip is read once, and that commit is what the version files are read at, what | ||
| a published tag is compared against, and what is merged into the base. A commit pushed to the branch | ||
| after the check is not merged by that run; run the step again for it. The branch name appears in | ||
| the merge message and in the messages, not in what the API is asked. | ||
|
|
||
| The tip is read when the step runs, which in a deploy that waits for an approval is later than the | ||
| run started. Pass `head`, the commit the run was started on (`github.sha` in a `workflow_dispatch` | ||
| run), and a branch whose tip is another commit is refused before anything is merged: the commit | ||
| that was built and reviewed is no longer the tip, so the deploy is started again. The template does | ||
| this; without `head`, the tip at that moment is what merges. | ||
|
|
||
| ## Inputs | ||
|
|
||
| | Input | Required | Default | What it is | | ||
| |---|---|---|---| | ||
| | `branch` | yes | | The release branch to merge, e.g. `release/M26.jigglypuff`. A branch name. | | ||
| | `head` | no | | The commit the release branch has to be at, as a full hash: `github.sha`, the commit the run was started on. Empty checks nothing. See [Versions it refuses](#versions-it-refuses). | | ||
| | `version` | yes | | The version being released: three or four numeric parts. | | ||
| | `base-branch` | no | `main` | The branch the release is merged into: `main`, or `master` where that is the repository's name for it. | | ||
| | `develop-branch` | no | `develop` | The branch the base is merged back into. Empty for a repository without one: only the first merge is made. Write `develop-branch: ''` in the caller itself; a `workflow_dispatch` input left empty takes its default, so the empty value cannot be passed through one. | | ||
| | `token` | no | `github.token` | The token for the merges and the reads. Needs `contents: write`. See [The token](#the-token). | | ||
|
|
||
| ## Outputs | ||
|
|
||
| | Output | What it is | | ||
| |---|---| | ||
| | `version` | The version released. | | ||
| | `branch` | The release branch merged. | | ||
| | `base-sha` | The commit on the base branch that holds the release: the merge commit, or the base's tip on a retry that found the merge done. The commit to check out, build from and publish the release at. | | ||
| | `conflict` | The branch a merge conflicted on, or empty. A conflict fails the step, so read it with `continue-on-error` or in a later `if: failure()` step. | | ||
|
|
||
| ## One deploy at a time | ||
|
|
||
| Two releases prepared with the same version, deployed at the same time, would merge side by side: | ||
| the second's changes would land on the base under a version the first had just published, and its | ||
| publish would be skipped as already done. The action cannot stop that on its own, since both runs | ||
| start before either has a tag to find. The caller does, with a `concurrency` group shared by every | ||
| deploy of the repository: | ||
|
|
||
| ```yaml | ||
| concurrency: | ||
| group: ${{ github.workflow }} | ||
| cancel-in-progress: false | ||
| ``` | ||
|
|
||
| The second run then waits for the first, finds the version tagged from another branch, and is | ||
| refused before merging. A repository that also deploys from another workflow gives it the same | ||
| group. GitHub keeps one run waiting per group: a third deploy started while one is already waiting | ||
| cancels the waiting one, which is started again. (`queue: max` would keep every waiting run, but | ||
| actionlint 1.7 does not know the key and the plugin repositories lint their workflows with it.) | ||
|
|
||
| ## The token | ||
|
|
||
| The job needs `contents: write`, and the token is used for the two merges and for the branch, tag, | ||
| release and file reads. It only ever reaches github-script's client: it is never handed to git, | ||
| never on a command line and never in the log. The caller's checkout, when there is one, can use | ||
| `persist-credentials: false`. | ||
|
|
||
| The default `github.token` is enough when the repository's rules allow what the action does: a | ||
| merge written straight to the base branch and to develop. The merges are made through the API, not | ||
| opened as pull requests, so a base branch that only accepts pull requests refuses the merge, with | ||
| any token that is not on the rule's bypass list; a rule that requires signed commits is satisfied, | ||
| since GitHub signs the merge commits it makes. GiveWP's rules are the reference shape: the branches | ||
| are protected from deletion and non-fast-forward pushes, and `github.token` writes the merges. The | ||
| human gate is not the token: the caller puts the job in an environment with required reviewers, and | ||
| the release branch reached this step after its review and QA approval. A repository whose rules | ||
| still need another actor should pass a GitHub App installation token minted in the run rather than | ||
| a personal access token. A release published with the default `github.token` triggers no `release` | ||
| workflow; one published with a personal access token or a GitHub App token does. | ||
|
|
||
| ## How it runs | ||
|
|
||
| One [actions/github-script](https://github.com/actions/github-script) step runs | ||
| [`release-merge.js`](./release-merge.js): the checks and the merges, all through github-script's | ||
| Octokit. The inputs reach the script through environment variables, never interpolated into it, so | ||
| a crafted value is data rather than part of the program. | ||
|
|
||
| The tests, `node --test` from the repository root, run the action against a recorded client and | ||
| assert the requests it sends and the ones it does not; no git and no network. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| # Merges a release: the release branch into the base branch (`main`, or `master` where that is the | ||
| # name), then the base branch back into develop. The part of a deploy that is the same for every | ||
| # plugin; what happens after the merge (building, publishing the GitHub release, wordpress.org, | ||
| # announcements) is the caller's, at the commit this one leaves on the base (`base-sha`). | ||
| # | ||
| # The checks and the merges live in release-merge.js, run by actions/github-script, so there is no | ||
| # dependency to install and nothing to build. Everything goes through the GitHub API: the branch, | ||
| # tag and release reads, the version-file reads and the merges themselves. No checkout is needed; | ||
| # the caller checks out `base-sha` afterwards to build from it. | ||
| name: Release merge | ||
| description: Merges the release branch into the base and the base back into develop | ||
|
|
||
| inputs: | ||
| branch: | ||
| description: >- | ||
| The release branch to merge, e.g. `release/M26.jigglypuff`. A branch name, not any git ref. | ||
| required: true | ||
| head: | ||
| description: >- | ||
| The commit the release branch has to be at, as a full hash; empty checks nothing. A deploy that | ||
| waits for an approval merges the branch's tip when the approval comes. Pass the commit the run | ||
| was started on (`github.sha`), and a branch that moved in between is refused instead of merged. | ||
| required: false | ||
| default: '' | ||
| version: | ||
| description: >- | ||
| The version being released: three or four numeric parts, such as 4.17.0 or 4.17.0.1, the rule | ||
| the release dashboard applies. It becomes the tag and the title of the GitHub release, so it has | ||
| to be the version the release branch holds: every file the branch's .puprc lists under | ||
| paths.versions is read, and a different version in any of them is refused. A pre-release such as | ||
| 4.17.0-beta.1 and an empty value are refused before anything is merged, and so is a version | ||
| whose tag already exists, unless that tag's release was published from this branch, which makes | ||
| the run a retry. Publishing the release is the caller's, after this action. | ||
| required: true | ||
| base-branch: | ||
| description: 'The branch the release is merged into: `main`, or `master` where that is the name.' | ||
| required: false | ||
| default: 'main' | ||
| develop-branch: | ||
| description: >- | ||
| The branch the base is merged back into after the release. Pass an empty value for a repository | ||
| that has no develop branch: the release is merged into the base and nothing is merged back. Write | ||
| it in the caller (`develop-branch: ''`); a workflow_dispatch input left empty takes its default. | ||
| required: false | ||
| default: 'develop' | ||
| token: | ||
| description: >- | ||
| Token the merges are made with, and the branch, tag, release and file reads. It needs | ||
| `contents: write` on the repository, so the job needs that permission too. GitHub makes the | ||
| merge commits under its identity and signs them. It only ever reaches github-script's client, | ||
| never git or the log. | ||
| required: false | ||
| default: ${{ github.token }} | ||
|
|
||
| outputs: | ||
| version: | ||
| description: 'The version released.' | ||
| value: ${{ steps.merge.outputs.version }} | ||
| branch: | ||
| description: 'The release branch merged.' | ||
| value: ${{ steps.merge.outputs.branch }} | ||
| base-sha: | ||
| description: >- | ||
| The commit on the base branch that holds the release: the merge commit, or the base's tip on a | ||
| retry that found the merge already done. The commit to check out, build from and publish the | ||
| release at. | ||
| value: ${{ steps.merge.outputs.base-sha }} | ||
| conflict: | ||
| description: >- | ||
| Which branch a merge conflicted on, `main` or `develop` for the defaults, or empty when the | ||
| merges went through. A conflict fails the step, so a caller reads this with `continue-on-error` | ||
| or in a later `if: failure()` step. | ||
| value: ${{ steps.merge.outputs.conflict }} | ||
|
|
||
| runs: | ||
| using: 'composite' | ||
| steps: | ||
| - name: Merge the release into the base and the base back into develop | ||
| id: merge | ||
| uses: actions/github-script@v9 | ||
| env: | ||
| # github-script's require() resolves against the workspace of the repository being built, | ||
| # not this action's directory, so the path has to be passed in. github.action_path is the | ||
| # documented expression for it. | ||
| ACTION_PATH: ${{ github.action_path }} | ||
| # Read from process.env rather than interpolated into the script, so an input is data and | ||
| # never part of the program. | ||
| INPUT_BRANCH: ${{ inputs.branch }} | ||
| INPUT_HEAD: ${{ inputs.head }} | ||
| INPUT_VERSION: ${{ inputs.version }} | ||
| INPUT_BASE_BRANCH: ${{ inputs.base-branch }} | ||
| INPUT_DEVELOP_BRANCH: ${{ inputs.develop-branch }} | ||
| with: | ||
| # Every read and the merges go through github-script's own client, built from this token. | ||
| github-token: ${{ inputs.token }} | ||
| script: | | ||
| const { runMerge } = require(`${process.env.ACTION_PATH}/release-merge.js`) | ||
| await runMerge({ github, core, env: process.env }) | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.