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
Open
Open
Changes from all commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
ef939b9
Share the version pattern and the real-exec test helper
glaubersilva 84d5961
Add the release-merge action
glaubersilva 1027c4a
Document the release-merge action and add a caller template
glaubersilva 8bce125
Refuse a published version whose tag does not contain the release branch
glaubersilva a74b151
Serialize the deploys of a repository in the caller template
glaubersilva f64b62d
Plain wording in the release-merge docs and the shared version helper
glaubersilva 23b6a71
Share the repository, tag and command helpers between the actions
glaubersilva 2e083ba
Rewrap the release-merge template's header comment
glaubersilva 2e84685
Refuse a version the release branch does not hold
glaubersilva 4779f4c
Rewrap the release-merge manifest's header comment
glaubersilva d2cf03a
Take the release branch whole in the caller template
glaubersilva b066fe6
Check the publish input before merging and name the file behind a reg…
glaubersilva 7ed3388
Publish the release at the commit the merge left on the base, not at …
glaubersilva 50b9cf8
Let develop-branch be empty, for a repository that has no develop branch
glaubersilva 6ade5dc
Say what the repository has to allow the token, and that the environm…
glaubersilva ff87d5d
Say that an empty develop-branch is written in the caller, not passed…
glaubersilva 073aae1
Leave publishing the release to the caller: release-merge only merges
glaubersilva 0891a30
Fix the sentence on what the rules have to allow the token
glaubersilva b11e46a
Keep every waiting deploy in the concurrency queue
glaubersilva f45a2cf
Drop the production input from the merge template: the dashboard no l…
glaubersilva f81f0cf
Back to the default concurrency queue: actionlint does not know queue…
glaubersilva f145579
Drop the merge-only caller template: a deploy workflow merges and pub…
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,193 @@ | ||
| # release-merge | ||
|
|
||
| Merges a release: the release branch into the base branch, then the base branch back into develop. | ||
| **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 | ||
| id: merge | ||
| with: | ||
| branch: ${{ inputs.branch }} | ||
| version: ${{ inputs.version }} | ||
|
|
||
| # 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 the | ||
| checkout it leaves on the merged base branch. | ||
|
|
||
| ## 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. Skipped when `develop-branch` is empty, for a | ||
| repository that has no develop branch. | ||
| The checkout is left on the base branch at the merged commit, clean, and that commit is the | ||
| `base-sha` output: what the caller builds from and tags. The merge commits are | ||
| made by `github-actions[bot]`; the pushes 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 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 | ||
|
|
||
| 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 (when there is a develop), 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. 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 fetch, the pushes and the tag 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 publish the release at; the checkout is left on it. | | ||
| | `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 fetch, the two pushes and the tag | ||
| and release reads. 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. | ||
|
|
||
| The default `github.token` is enough when the repository's rules allow what the action does: a | ||
| direct push to the base branch and to develop. The merges are pushed, not opened as pull requests, | ||
| so a base branch that only accepts pull requests refuses the push, with any token that is not on | ||
| the rule's bypass list. GiveWP's rules are the reference shape: the branches are protected from | ||
| deletion and non-fast-forward pushes, and `github.token` pushes 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. | ||
|
|
||
| ## 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 | ||
|
|
||
| One [actions/github-script](https://github.com/actions/github-script) step runs | ||
| [`release-merge.js`](./release-merge.js): the checks and the merges. 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 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. |
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,92 @@ | ||
| # 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. The merges are git in the caller's checkout; the tag | ||
| # and release reads go through the GitHub API. | ||
| 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. 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. 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 fetch and the pushes are made with, and the tag and release reads. 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`. | ||
| 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 publish the release at; it is also the | ||
| commit the checkout is left on. | ||
| 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_VERSION: ${{ inputs.version }} | ||
| INPUT_BASE_BRANCH: ${{ inputs.base-branch }} | ||
| INPUT_DEVELOP_BRANCH: ${{ inputs.develop-branch }} | ||
| 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 }) | ||
|
|
||
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.
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.
One token does both the pushes and the release. On repos with a tag ruleset,
github.tokencan't create the tag, so the caller has to pass a bot token. If that bot can also bypass the "only PR pushes" rule onmain, the action could push a merge nobody reviewed. Could the push token and the release token be separate inputs? Or could there be amerge: falsemode for repos where the release PR is merged by a person and the action just checks the version and publishes?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.
Right on both counts. I kept one token per action and wrote in each README what the repository has to allow it, instead of adding inputs. The reasoning:
The token is the job's own
GITHUB_TOKEN, scoped bypermissions: contents: writeand gone when the run ends. On GiveWP it is enough: themasterruleset blocks deletion and non-fast-forward pushes, there is no tag ruleset, and a release published with it does not trigger any workflow that runs on thereleaseevent.TEC needs
tec-botbecause its tag ruleset blocks tag creation, andGITHUB_TOKENcannot be on a bypass list. A bot PAT that bypasses every rule is a larger key than the problem it solves. If the ruleset blocked deletion and updates but not creation,github.tokenwould publish there too, with the environment's required reviewers as the gate on who creates tags.On "a merge nobody reviewed": the action is built for the NX1 release dashboard, which is the only thing that dispatches the deploy workflow. In the dashboard a release goes through three steps per repository, and the deploy is the last one: first
release-prepbumps the version and writes the changelog on the release branch and a reviewer approves that branch (on its pull request, or in the dashboard); then the RC zip is built and a QA person tests and approves it; only then can someone click "Release Now", which dispatches the workflow that runsrelease-merge. So by the time the action pushes the merge, the branch has been reviewed and the build tested, and the person who approves theliveenvironment authorizes that specific run. That gate does not depend on which token pushes. Separate push and release tokens would only matter on a repository that trusts the token to rewritemainbut not to create a tag, so I did not add them.merge: falseis runningrelease-publishon its own (#16), with the branch astarget, which is what your "Release: Publish" workflow does today. If TEC keeps the ruleset, a GitHub App installation token minted in the run (actions/create-github-app-token) passed astokenis the safer form of the bypass; the README says it does trigger workflows on thereleaseevent. Does that cover the TEC case?