From 0a9955b267d82ca0cbc2d2e61437626d787ddaeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20O=E2=80=99Mara?= Date: Wed, 4 Nov 2026 09:55:58 +1100 Subject: [PATCH 1/2] Merge a release's back-merge once TagBot has tagged the release With the input `auto-merge`, the back-merge workflow approves a release's back-merge as github-actions[bot] and merges it with the ReleaseFlow app's token, or RELEASEFLOW_TOKEN, once TagBot has tagged the release at the commit that the back-merge brings, GitHub's state of it is clean, and it doesn't conflict. It waits for the tag because the back-merge's merge commit may have the release's tree, which TagBot could otherwise tag instead of the release's commit. It runs on the caller's `workflow_run` of TagBot and of the required checks, and skips the other pull requests' checks; GitHub's auto-merge isn't used, so nothing else can merge itself. Each run reports, in its notices and job summary, what each back-merge waits for, and names the setting or permission to change where approving or merging is refused; the README lists these under Troubleshooting the back-merge. This repository's own back-merges use it. --- .github/workflows/Self-BackMerge.yml | 5 + .github/workflows/back-merge.yml | 159 +++++++++++++++++++++++++++ README.md | 58 +++++++++- ReleaseFlow/CHANGELOG.md | 5 + ReleaseFlow/test/back_merge.jl | 137 ++++++++++++++++++++--- 5 files changed, 349 insertions(+), 15 deletions(-) diff --git a/.github/workflows/Self-BackMerge.yml b/.github/workflows/Self-BackMerge.yml index 349c40b..a24fcbd 100644 --- a/.github/workflows/Self-BackMerge.yml +++ b/.github/workflows/Self-BackMerge.yml @@ -1,11 +1,15 @@ # This repository's own caller of the reusable back-merge.yml, which other repositories call as BioJulia/Workflows/.github/workflows/back-merge.yml@v1; the Self- files apply this repository's workflows to itself. # Once a release or hotfix merges into master, this repository's reusable back-merge workflow opens the pull request that brings master back into develop. +# It merges that pull request once TagBot has tagged the release and the required checks, of Self: Gitflow and CI, pass; their runs, and TagBot's, run this workflow again. name: "Self: Back-merge" on: push: branches: - master + workflow_run: + workflows: [TagBot, "Self: Gitflow", CI] + types: [completed] workflow_dispatch: permissions: {} @@ -19,6 +23,7 @@ jobs: with: main-branch: master project: ReleaseFlow + auto-merge: true secrets: RELEASEFLOW_APP_CLIENT_ID: ${{ secrets.RELEASEFLOW_APP_CLIENT_ID }} RELEASEFLOW_APP_PRIVATE_KEY: ${{ secrets.RELEASEFLOW_APP_PRIVATE_KEY }} diff --git a/.github/workflows/back-merge.yml b/.github/workflows/back-merge.yml index 7c8c014..8c4102c 100644 --- a/.github/workflows/back-merge.yml +++ b/.github/workflows/back-merge.yml @@ -35,6 +35,28 @@ # Without either, the default token opens the pull request, which says to close and reopen it, so that the checks run. # Either way, the repository must allow GitHub Actions to create pull requests, in Settings → Actions → General. # +# With `auto-merge: true`, a release's back-merge, from the main branch, merges itself once it's ready: TagBot has tagged the release at the commit that it brings, its required checks pass, and it doesn't conflict. +# It waits for the tag because its merge commit may have the release's tree, which TagBot could otherwise tag instead of the release's commit. +# github-actions[bot] approves it, with the job's own token, and the app's token, or RELEASEFLOW_TOKEN, merges it, so that the merge starts the develop branch's workflows; nothing else is merged, and GitHub's auto-merge isn't used. +# A back-merge becomes ready when TagBot tags the release or when its checks complete, so the caller also runs on those workflows' runs, naming every workflow whose checks the ruleset requires: +# +# on: +# push: +# branches: [main] +# workflow_run: +# workflows: [TagBot, Gitflow, Unit tests] +# types: [completed] +# workflow_dispatch: +# jobs: +# back-merge: +# uses: BioJulia/Workflows/.github/workflows/back-merge.yml@v1 +# with: +# auto-merge: true +# +# A `workflow_run` runs on the default branch, so the environment must allow it too. +# The repository must also allow GitHub Actions to approve pull requests, in the same setting as creating them. +# Each run reports, in its notices and its job summary, what each back-merge waits for, and which setting or permission to change where one is missing. +# # The job runs in the deployment environment `environment`, `releaseflow` unless given: keep the secrets there, as environment secrets, and limit the environment to the branches that it brings, so that a workflow on any other branch can't read them. name: "Reusable: Back-merge" @@ -65,6 +87,10 @@ on: description: The deployment environment that holds the secrets, limited to the branches that it brings. type: string default: releaseflow + auto-merge: + description: Whether to approve and merge a release's back-merge once TagBot has tagged the release, its checks pass and it doesn't conflict, on the caller's `workflow_run` of TagBot and of the checks. + type: boolean + default: false secrets: RELEASEFLOW_APP_CLIENT_ID: description: The client ID of a GitHub App, installed on the repository, whose token opens the pull request so that its checks run. @@ -81,6 +107,8 @@ permissions: {} jobs: back-merge: name: Back-merge + # A `workflow_run` concerns a back-merge only if it's TagBot's, which may have tagged a release, or a check's of a back-merge branch; the other pull requests' checks don't start the job. + if: ${{ github.event_name != 'workflow_run' || (inputs.auto-merge && (github.event.workflow_run.name == 'TagBot' || startsWith(github.event.workflow_run.head_branch, 'back-merge/'))) }} runs-on: ubuntu-latest permissions: contents: write @@ -109,6 +137,8 @@ jobs: client-id: ${{ secrets.RELEASEFLOW_APP_CLIENT_ID }} private-key: ${{ secrets.RELEASEFLOW_APP_PRIVATE_KEY }} - name: Open the pull request that brings one branch into another + # The branch moves by a push, or the job is run by hand; a `workflow_run` only merges. + if: ${{ github.event_name != 'workflow_run' }} env: # The pull request's token: the app's, or the personal one, or the default one, whose pull requests start no workflows. GH_TOKEN: ${{ steps.app.outputs.token || secrets.RELEASEFLOW_TOKEN || github.token }} @@ -181,3 +211,132 @@ jobs: It was opened with the default token, which doesn't start workflows, so close and reopen it to run the checks; a GitHub App or the secret RELEASEFLOW_TOKEN avoids this." fi gh pr create --repo "$REPOSITORY" --head "$branch" --base "$into" --title "$title" --body "$body" + # With `auto-merge`, the back-merges of releases merge themselves once they're ready: TagBot has tagged the release, at the commit that the back-merge brings, the required checks pass, and nothing conflicts. + # The merge waits for the tag because a back-merge's merge commit may have the release's tree, which TagBot could otherwise tag instead of the release's commit. + - name: Merge the back-merges that are ready + if: ${{ inputs.auto-merge }} + env: + # The merge's token: the app's, or the personal one, so that the merge into the develop branch starts its workflows, which a merge with the default token wouldn't. + GH_TOKEN: ${{ steps.app.outputs.token || secrets.RELEASEFLOW_TOKEN }} + HAS_TOKEN: ${{ steps.app.outputs.token != '' || secrets.RELEASEFLOW_TOKEN != '' }} + # The approval's token: the job's own, as github-actions[bot], a reviewer other than the app or the token's owner that opened the pull request. + DEFAULT_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + RELEASEFLOW_MAIN_BRANCH: ${{ inputs.main-branch }} + RELEASEFLOW_DEVELOP_BRANCH: ${{ inputs.develop-branch }} + FROM: ${{ inputs.from }} + INTO: ${{ inputs.into }} + run: | + set -euo pipefail + from="${FROM:-$RELEASEFLOW_MAIN_BRANCH}" + into="${INTO:-$RELEASEFLOW_DEVELOP_BRANCH}" + # Each outcome is a notice on the run, and a line of the job's summary; a failure is an error, and fails the step once every back-merge is handled. + summary="${GITHUB_STEP_SUMMARY:-/dev/null}" + echo "## Back-merges" >> "$summary" + report() { + echo "::notice title=Back-merge::$1" + echo "- $1" >> "$summary" + } + failed=false + fail() { + echo "::error title=Back-merge::$1" + echo "- $1" >> "$summary" + failed=true + } + + # Only a release's back-merge, from the main branch, is named after the version whose tag it waits for. + if [ "$from" != "$RELEASEFLOW_MAIN_BRANCH" ]; then + report "Only the back-merges of releases, from $RELEASEFLOW_MAIN_BRANCH, merge themselves; merge the one from $from by hand." + exit 0 + fi + if [ "$HAS_TOKEN" != "true" ]; then + fail "Merging a back-merge needs the ReleaseFlow app's secrets, RELEASEFLOW_APP_CLIENT_ID and RELEASEFLOW_APP_PRIVATE_KEY, or RELEASEFLOW_TOKEN, in the environment of the job, so that the merge starts the workflows of $into; see BioJulia/Workflows' README, Creating the ReleaseFlow app." + exit 1 + fi + + # The open back-merges of releases into the develop branch: their number, branch and head commit. + back_merges=$(GH_TOKEN="$DEFAULT_TOKEN" gh pr list --repo "$REPOSITORY" --base "$into" --state open --json number,headRefName,headRefOid \ + --jq '.[] | select(.headRefName | test("^back-merge/v[0-9]+\\.[0-9]+\\.[0-9]+$")) | "\(.number) \(.headRefName) \(.headRefOid)"') + if [ -z "$back_merges" ]; then + report "There's no open back-merge of a release into $into." + exit 0 + fi + + # The list comes on its own descriptor, so that nothing in the loop reads it. + while read -r number branch head <&3; do + version="${branch#back-merge/}" + name="#$number, $branch" + # The tag, which TagBot makes once the registry merges the release. + if ! git ls-remote --exit-code --tags origin "refs/tags/$version" > /dev/null; then + report "$name waits for TagBot to tag $version." + continue + fi + git fetch -q --force origin "+refs/tags/$version:refs/tags/$version" "+refs/heads/$branch:refs/remotes/origin/$branch" + tagged=$(git rev-parse "$version^{commit}") + # The release's commit is in the back-merge's history, also once a maintainer has merged the develop branch into it to resolve a conflict. + if ! git merge-base --is-ancestor "$tagged" "$head"; then + fail "$name isn't merged: $version is at ${tagged:0:7}, which the back-merge, at ${head:0:7}, doesn't bring, so TagBot tagged another commit than the release's; move the tag to the release's commit, and run this workflow again." + continue + fi + + # The approval, once, as github-actions[bot]. + approved=$(GH_TOKEN="$DEFAULT_TOKEN" gh pr view "$number" --repo "$REPOSITORY" --json latestReviews \ + --jq '[.latestReviews[] | select((.author.login | startswith("github-actions")) and .state == "APPROVED")] | length') + if [ "$approved" = "0" ]; then + if message=$(GH_TOKEN="$DEFAULT_TOKEN" gh pr review "$number" --repo "$REPOSITORY" --approve --body "TagBot has tagged $version at ${tagged:0:7}, the release's commit, so this back-merge can merge." 2>&1 > /dev/null); then + report "Approved $name, since TagBot has tagged $version at ${tagged:0:7}." + elif [[ "$message" == *"not permitted to approve"* || "$message" == *"Can not approve"* ]]; then + fail "GitHub Actions couldn't approve $name: turn on Settings → Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests. GitHub said: ${message//$'\n'/ }" + continue + else + fail "GitHub Actions couldn't approve $name: ${message//$'\n'/ }" + continue + fi + fi + + # Whether GitHub would merge it: its state folds in the ruleset's required checks and reviews, and conflicts; GitHub computes it after a change, so it may first be unknown. + state=UNKNOWN + for _ in 1 2 3 4 5 6; do + read -r state decision current < <(GH_TOKEN="$DEFAULT_TOKEN" gh pr view "$number" --repo "$REPOSITORY" --json mergeStateStatus,reviewDecision,headRefOid \ + --jq '"\(.mergeStateStatus) \(.reviewDecision // "NONE") \(.headRefOid)"') + [ "$state" != "UNKNOWN" ] && break + sleep "${RELEASEFLOW_RETRY_SECONDS:-5}" + done + case "$state" in + CLEAN | HAS_HOOKS) + # The merge only goes ahead at the head that was checked, and GitHub applies the ruleset to it again. + if message=$(gh pr merge "$number" --repo "$REPOSITORY" --merge --match-head-commit "$current" 2>&1 > /dev/null); then + report "Merged $name into $into, after TagBot tagged $version at ${tagged:0:7}." + elif [[ "$message" == *"Head branch was modified"* ]]; then + report "$name moved while it was being merged; its checks run again, and their completion merges it." + elif [[ "$message" == *"Resource not accessible"* || "$message" == *"403"* ]]; then + fail "The token couldn't merge $name: the ReleaseFlow app, or RELEASEFLOW_TOKEN, needs Contents and Pull requests: Read and write on $REPOSITORY. GitHub said: ${message//$'\n'/ }" + else + fail "The token couldn't merge $name: ${message//$'\n'/ }" + fi + ;; + DIRTY) + report "$name conflicts with $into: merge $into into $branch, resolve the conflicts there, and push; its checks then run again, and their completion merges it." + ;; + BLOCKED) + if [ "$decision" = "REVIEW_REQUIRED" ] || [ "$decision" = "CHANGES_REQUESTED" ]; then + report "$name needs a review that github-actions[bot] can't give, such as a code owner's for changes to /.github/; the reviewer merges it on approving." + else + report "$name waits for its required checks; their completion merges it." + fi + ;; + BEHIND) + report "$name is behind $into, whose ruleset requires branches to be up to date: merge $into into $branch; its checks then run again, and their completion merges it." + ;; + UNSTABLE) + report "$name has checks that fail, though the ruleset doesn't require them, so it isn't merged by itself: look at them, and merge it by hand." + ;; + *) + report "$name can't be merged yet, as GitHub's state of it is $state; the next completion of its checks, or of TagBot, tries again." + ;; + esac + done 3<<< "$back_merges" + + if [ "$failed" = true ]; then + exit 1 + fi diff --git a/README.md b/README.md index 4146880..9ac63b0 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,40 @@ From the main branch, the pull request's branch is `back-merge/vX.Y.Z`, named af The `releaseflow` environment then also allows the branch that it brings. Under gitflow, ReleaseFlow's source branch check allows a `back-merge/*` branch only into develop, so a merge into the main branch suits a repository that doesn't require the gitflow checks there. +With `auto-merge: true`, a release's back-merge merges itself once it's ready: TagBot has tagged the release at the commit that the back-merge brings, the required checks pass, and it doesn't conflict. +It waits for the tag because the back-merge's merge commit may have the release's tree, which TagBot could otherwise tag instead of the release's commit. +`github-actions[bot]` approves it, with the job's own token, a reviewer other than the app that opened it, and the app's token, or `RELEASEFLOW_TOKEN`, merges it, so that the merge starts `develop`'s workflows. +Nothing else is merged, and GitHub's auto-merge isn't used, so the repository doesn't need it on. +A back-merge becomes ready when TagBot tags the release or when its checks complete, so the caller also runs on those workflows' runs, naming TagBot's workflow and every workflow whose checks the ruleset requires: + +```yaml +name: Back-merge +on: + push: + branches: [main] + workflow_run: + workflows: [TagBot, Gitflow, Unit tests] # TagBot's workflow, and those of the required checks, by their names + types: [completed] + workflow_dispatch: +permissions: {} +jobs: + back-merge: + uses: BioJulia/Workflows/.github/workflows/back-merge.yml@v1 + permissions: + contents: write + pull-requests: write + with: + auto-merge: true + secrets: + RELEASEFLOW_APP_CLIENT_ID: ${{ secrets.RELEASEFLOW_APP_CLIENT_ID }} + RELEASEFLOW_APP_PRIVATE_KEY: ${{ secrets.RELEASEFLOW_APP_PRIVATE_KEY }} + RELEASEFLOW_TOKEN: ${{ secrets.RELEASEFLOW_TOKEN }} +``` + +The job skips the runs of other pull requests' checks, so it only starts for TagBot's runs and the back-merges' own. +A back-merge that needs a review that `github-actions[bot]` can't give, such as a code owner's for a hotfix that changes `/.github/`, waits for it, and the reviewer merges it on approving. +Each run says, in its notices and its job summary, what each back-merge waits for, and which setting or permission to change where one is missing; [Troubleshooting the back-merge](#troubleshooting-the-back-merge) lists them. + And the dependents' tests, in `.github/workflows/Dependents.yml`, which run the tests of the packages that depend on this one with the pull request's branch in its place: ```yaml @@ -243,6 +277,7 @@ A package can follow the major tag, `v1`, or pin a release, such as `v1.0.0`; De | `step` | `auto` | release | The step of the version, `auto`, `patch`, `minor` or `major`. | | `pin-action` | none | release | An action whose pins in the workflows take the new version, such as `BioJulia/Workflows` for this repository. | | `environment` | `releaseflow` | back-merge, release | The deployment environment that holds the secrets. | +| `auto-merge` | `false` | back-merge | Whether a release's back-merge approves and merges itself once TagBot has tagged the release, its checks pass and it doesn't conflict. | For example, `with: { main-branch: master }` for a repository whose main branch is `master`. @@ -273,7 +308,7 @@ Any workflow that runs in a repository can read its repository and organization An environment's secrets reach only the jobs on the branches that the environment allows, so the release and back-merge jobs run in the environment that their input `environment` names, `releaseflow`: 1. Open the repository's Settings → Environments, and choose "New environment", named `releaseflow`. -2. Under "Deployment branches and tags", choose "Selected branches and tags", and add rules for the main branch, such as `master`, where the back-merge runs; the branch that releases are started from in the Actions tab, such as `develop`, the default branch, where the tag workflow runs too; and tags matching `v*`, on which the tag workflow runs, whose ruleset limits who can make them. +2. Under "Deployment branches and tags", choose "Selected branches and tags", and add rules for the main branch, such as `master`, where the back-merge runs; the branch that releases are started from in the Actions tab, such as `develop`, the default branch, where the tag workflow runs too, and the back-merge with `auto-merge` on TagBot's and the checks' runs; and tags matching `v*`, on which the tag workflow runs, whose ruleset limits who can make them. 3. Under "Environment secrets", add the ReleaseFlow credentials: `RELEASEFLOW_APP_CLIENT_ID` and `RELEASEFLOW_APP_PRIVATE_KEY` for the app, or `RELEASEFLOW_TOKEN` for a personal access token. GitHub uses the environment's secrets in place of secrets of the same name that the caller passes, so the caller needs no change. @@ -329,6 +364,27 @@ GitHub shows the token's owner as the author of the branches and pull requests, 6. Choose "Generate token", and copy it; GitHub shows it only once. 7. Store it as an environment secret named `RELEASEFLOW_TOKEN`, in each repository's `releaseflow` environment. +### Troubleshooting the back-merge + +Each run of the back-merge says what it did, and what each back-merge waits for, in its notices and in its job summary, under "Back-merges". +Where a run fails, its message names what to change: + +- "Merging a back-merge needs the ReleaseFlow app's secrets": the job has neither the app's secrets nor `RELEASEFLOW_TOKEN`; add them to the `releaseflow` environment, as described in [Creating the ReleaseFlow app](#creating-the-releaseflow-app). + The default token's merge wouldn't start `develop`'s workflows, so the back-merge isn't merged with it. +- "GitHub Actions couldn't approve": turn on Settings → Actions → General → Workflow permissions → "Allow GitHub Actions to create and approve pull requests". +- "The token couldn't merge … needs Contents and Pull requests: Read and write": give the ReleaseFlow app, or `RELEASEFLOW_TOKEN`, those permissions on the repository. +- "isn't merged: vX.Y.Z is at …, which the back-merge … doesn't bring": TagBot tagged another commit than the release's; move the tag to the release's commit, and run the back-merge from the Actions tab. +- "Branch develop is not allowed to deploy to releaseflow due to environment protection rules", before any step runs: a `workflow_run` runs on the default branch, which the `releaseflow` environment must allow, as described in [The ReleaseFlow environment](#the-releaseflow-environment). + +And where a back-merge stays open without a failure: + +- "waits for TagBot": the registry hasn't merged the release yet, or TagBot hasn't run; TagBot's completion runs the back-merge again. +- "waits for its required checks": a required check hasn't passed; if it has passed meanwhile, the caller's `workflow_run` doesn't name its workflow, so add it. +- "needs a review that github-actions[bot] can't give": the ruleset requires a code owner's review, such as for changes to `/.github/`; the reviewer merges it on approving. +- "conflicts with develop", or "is behind develop": merge `develop` into the back-merge's branch, and push; its checks then run again, and their completion merges it. +- "has checks that fail, though the ruleset doesn't require them": look at them, and merge it by hand. +- No run at all after TagBot's: the caller's `workflow_run` must name TagBot's workflow by its `name:`, and the job only follows a workflow named `TagBot`. + ## The checks - **Source branch.** diff --git a/ReleaseFlow/CHANGELOG.md b/ReleaseFlow/CHANGELOG.md index 17111e5..0225462 100644 --- a/ReleaseFlow/CHANGELOG.md +++ b/ReleaseFlow/CHANGELOG.md @@ -6,6 +6,11 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Added + +- The back-merge workflow's input `auto-merge` approves and merges a release's back-merge once TagBot has tagged the release at the commit that the back-merge brings, its required checks pass, and it doesn't conflict, on the caller's `workflow_run` of TagBot and of the checks, without GitHub's auto-merge; each run says what each back-merge waits for, and which setting or permission is missing. + This repository's own back-merges use it. + ## [1.3.1] - 2026-11-03 ### Fixed diff --git a/ReleaseFlow/test/back_merge.jl b/ReleaseFlow/test/back_merge.jl index f04ab9e..8971825 100644 --- a/ReleaseFlow/test/back_merge.jl +++ b/ReleaseFlow/test/back_merge.jl @@ -1,23 +1,10 @@ # Back-merge # ---------- -# The shell of the back-merge workflow's step, read from the workflow: the block after its `run: |`, without its indentation. -function back_merge_script() - lines = readlines(normpath(joinpath(@__DIR__, "..", "..", ".github", "workflows", "back-merge.yml"))) - start = findfirst(line -> endswith(line, "run: |"), lines) - indentation = length(match(r"^ *", lines[start + 1]).match) - block = String[] - for line in lines[(start + 1):end] - !isempty(strip(line)) && length(match(r"^ *", line).match) < indentation && break - push!(block, isempty(strip(line)) ? "" : line[(indentation + 1):end]) - end - return join(block, '\n') * '\n' -end - @testset "Back-merge" begin directory = mktempdir() script = joinpath(directory, "step.sh") - write(script, back_merge_script()) + write(script, workflow_step("back-merge.yml", "Open the pull request that brings one branch into another")) # A stand-in for `gh`: it logs its calls with their tokens, creates refs in the origin for `api`, unless `REFUSE_DEFAULT` refuses the default token as GitHub may, and reports `OPEN_PRS` open pull requests. bin = mkpath(joinpath(directory, "bin")) write( @@ -122,3 +109,125 @@ end result = step(; from = "maintenance/1.x", into = "master") @test result.code == 0 && occursin("master already has maintenance/1.x's commit", result.output) && isempty(result.calls) end + +# The back-merge's merge, once TagBot has tagged the release and GitHub would merge it. +@testset "Merging back-merges" begin + directory = mktempdir() + script = joinpath(directory, "step.sh") + write(script, workflow_step("back-merge.yml", "Merge the back-merges that are ready")) + # A stand-in for `gh`: it logs its calls with their tokens, lists `BACK_MERGES`, reports `APPROVED` approvals and the state `STATE`, and refuses the review or the merge with `REFUSE_REVIEW` or `REFUSE_MERGE`'s message. + bin = mkpath(joinpath(directory, "bin")) + write( + joinpath(bin, "gh"), raw""" + #!/bin/bash + echo "gh $* (token $GH_TOKEN)" >> "$LOG" + case "$1 $2" in + "pr list") echo -n "$BACK_MERGES";; + "pr view") case "$*" in *latestReviews*) echo "${APPROVED:-0}";; *) echo "$STATE";; esac;; + "pr review") if [ -n "$REFUSE_REVIEW" ]; then echo "$REFUSE_REVIEW" >&2; exit 1; fi;; + "pr merge") if [ -n "$REFUSE_MERGE" ]; then echo "$REFUSE_MERGE" >&2; exit 1; fi;; + esac + """ + ) + chmod(joinpath(bin, "gh"), 0o755) + origin = joinpath(directory, "origin.git") + run(pipeline(`git init -q --bare $(origin)`)) + work = repository() + git(work, "remote", "set-url", "origin", origin) + git(work, "config", "tag.gpgsign", "false") + commit!(work, "Start", "Project.toml" => project("0.1.0")) + git(work, "push", "-q", "origin", "HEAD:master", "HEAD:develop") + release = commit!(work, "Release 0.1.1", "Project.toml" => project("0.1.1")) + git(work, "push", "-q", "origin", "HEAD:master", "HEAD:refs/heads/back-merge/v0.1.1") + log = joinpath(directory, "log") + summary = joinpath(directory, "summary.md") + + function step(; back_merges = "7 back-merge/v0.1.1 $(release)\n", state = "CLEAN APPROVED $(release)", approved = "0", has_token = "true", refuse_review = "", refuse_merge = "", from = "") + write(log, "") + write(summary, "") + environment = Dict( + "PATH" => "$(bin):$(ENV["PATH"])", "LOG" => log, "GITHUB_STEP_SUMMARY" => summary, "RELEASEFLOW_RETRY_SECONDS" => "0", + "GH_TOKEN" => has_token == "true" ? "token" : "", "HAS_TOKEN" => has_token, "DEFAULT_TOKEN" => "default", + "REPOSITORY" => "Example/Toy.jl", "RELEASEFLOW_MAIN_BRANCH" => "master", "RELEASEFLOW_DEVELOP_BRANCH" => "develop", "FROM" => from, "INTO" => "", + "BACK_MERGES" => back_merges, "STATE" => state, "APPROVED" => approved, "REFUSE_REVIEW" => refuse_review, "REFUSE_MERGE" => refuse_merge, + ) + output = IOBuffer() + process = run(pipeline(Cmd(addenv(`bash $(script)`, environment); dir = work, ignorestatus = true); stdout = output, stderr = output)) + calls = readlines(log) + return ( + code = process.exitcode, output = String(take!(output)), summary = read(summary, String), calls = calls, + approvals = filter(call -> startswith(call, "gh pr review"), calls), merges = filter(call -> startswith(call, "gh pr merge"), calls), + ) + end + + # Without the tag, the back-merge waits for TagBot, without approving or merging. + result = step() + @test result.code == 0 && occursin("#7, back-merge/v0.1.1 waits for TagBot to tag v0.1.1.", result.summary) + @test isempty(result.approvals) && isempty(result.merges) + # Without open back-merges, there's nothing to do. + result = step(; back_merges = "") + @test result.code == 0 && occursin("There's no open back-merge of a release into develop.", result.summary) + # Only the back-merges of releases, from the main branch, merge themselves. + result = step(; from = "maintenance/1.x") + @test result.code == 0 && occursin("merge the one from maintenance/1.x by hand", result.summary) && isempty(result.calls) + # Without the app's token or RELEASEFLOW_TOKEN, the merge couldn't start develop's workflows, so it says which secrets to give. + result = step(; has_token = "false") + @test result.code == 1 && occursin("::error title=Back-merge::Merging a back-merge needs the ReleaseFlow app's secrets", result.output) && isempty(result.calls) + + # Once TagBot tags the release's commit, github-actions[bot] approves the back-merge with the job's token, and the app's token merges it at the head that was checked. + git(work, "tag", "v0.1.1", release) + git(work, "push", "-q", "origin", "v0.1.1") + result = step() + @test result.code == 0 + @test only(result.approvals) == "gh pr review 7 --repo Example/Toy.jl --approve --body TagBot has tagged v0.1.1 at $(first(release, 7)), the release's commit, so this back-merge can merge. (token default)" + @test only(result.merges) == "gh pr merge 7 --repo Example/Toy.jl --merge --match-head-commit $(release) (token token)" + @test occursin("Merged #7, back-merge/v0.1.1 into develop, after TagBot tagged v0.1.1 at $(first(release, 7)).", result.summary) + # An approval already given isn't given again. + result = step(; approved = "1") + @test result.code == 0 && isempty(result.approvals) && length(result.merges) == 1 + # Once a maintainer has merged develop into the back-merge to resolve a conflict, the release's commit is still in its history. + branch!(work, "back-merge/v0.1.1", "v0.1.1") + git(work, "merge", "-q", "--no-edit", "origin/develop") + resolved = commit!(work, "Resolve", "resolved.txt" => "resolved") + git(work, "push", "-q", "origin", "HEAD:refs/heads/back-merge/v0.1.1") + result = step(; back_merges = "7 back-merge/v0.1.1 $(resolved)\n", state = "CLEAN APPROVED $(resolved)") + @test result.code == 0 && only(result.merges) == "gh pr merge 7 --repo Example/Toy.jl --merge --match-head-commit $(resolved) (token token)" + + # A back-merge that GitHub wouldn't merge yet says what it waits for, and isn't merged. + waits(state) = step(; state = "$(state) $(release)") + result = waits("BLOCKED REVIEW_REQUIRED") + @test result.code == 0 && isempty(result.merges) && occursin("needs a review that github-actions[bot] can't give, such as a code owner's", result.summary) + result = waits("BLOCKED APPROVED") + @test result.code == 0 && isempty(result.merges) && occursin("waits for its required checks; their completion merges it.", result.summary) + result = waits("DIRTY APPROVED") + @test result.code == 0 && isempty(result.merges) && occursin("conflicts with develop: merge develop into back-merge/v0.1.1", result.summary) + result = waits("BEHIND APPROVED") + @test result.code == 0 && isempty(result.merges) && occursin("is behind develop", result.summary) + result = waits("UNSTABLE APPROVED") + @test result.code == 0 && isempty(result.merges) && occursin("merge it by hand", result.summary) + # A state that stays unknown is retried, then reported. + result = waits("UNKNOWN APPROVED") + @test result.code == 0 && isempty(result.merges) && count(call -> occursin("mergeStateStatus", call), result.calls) == 6 + @test occursin("GitHub's state of it is UNKNOWN", result.summary) + + # A setting or permission that's missing is named, with GitHub's own message. + result = step(; refuse_review = "failed to create review: GraphQL: GitHub Actions is not permitted to approve pull requests. (addPullRequestReview)") + @test result.code == 1 && isempty(result.merges) + @test occursin("::error title=Back-merge::GitHub Actions couldn't approve #7, back-merge/v0.1.1: turn on Settings → Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests.", result.output) + @test occursin("GitHub said: failed to create review: GraphQL: GitHub Actions is not permitted to approve pull requests.", result.output) + result = step(; refuse_merge = "GraphQL: Resource not accessible by integration (mergePullRequest)") + @test result.code == 1 && occursin("needs Contents and Pull requests: Read and write on Example/Toy.jl. GitHub said: GraphQL: Resource not accessible by integration", result.summary) + result = step(; refuse_merge = "GraphQL: Pull request is not mergeable (mergePullRequest)") + @test result.code == 1 && occursin("The token couldn't merge #7, back-merge/v0.1.1: GraphQL: Pull request is not mergeable", result.summary) + # A back-merge that moves while it's being merged waits for its checks again. + result = step(; refuse_merge = "GraphQL: Head branch was modified. Review and try the merge again. (mergePullRequest)") + @test result.code == 0 && occursin("moved while it was being merged", result.summary) + + # A tag that TagBot made at a commit that the back-merge doesn't bring is an error, and the back-merge isn't merged. + other = commit!(work, "Elsewhere", "elsewhere.txt" => "x") + git(work, "tag", "-f", "v0.1.1", other) + git(work, "push", "-q", "--force", "origin", "v0.1.1") + result = step() + @test result.code == 1 && isempty(result.approvals) && isempty(result.merges) + @test occursin("::error title=Back-merge::#7, back-merge/v0.1.1 isn't merged: v0.1.1 is at $(first(other, 7)), which the back-merge, at $(first(release, 7)), doesn't bring", result.output) +end From 6898c8617f348b85f5604c398668c45bd24ea51d Mon Sep 17 00:00:00 2001 From: "biojulia-releaseflow[bot]" <344686954+biojulia-releaseflow[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:29:24 +0000 Subject: [PATCH 2/2] Release 1.4.0 --- .github/workflows/gitflow.yml | 10 +++++----- .github/workflows/oneflow.yml | 10 +++++----- .github/workflows/register.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/trunk.yml | 10 +++++----- ReleaseFlow/CHANGELOG.md | 5 ++++- ReleaseFlow/Project.toml | 2 +- 7 files changed, 22 insertions(+), 19 deletions(-) diff --git a/.github/workflows/gitflow.yml b/.github/workflows/gitflow.yml index f103aec..366faa1 100644 --- a/.github/workflows/gitflow.yml +++ b/.github/workflows/gitflow.yml @@ -57,7 +57,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - uses: BioJulia/Workflows@v1.3.1 + - uses: BioJulia/Workflows@v1.4.0 with: check: source model: gitflow @@ -75,7 +75,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - uses: BioJulia/Workflows@v1.3.1 + - uses: BioJulia/Workflows@v1.4.0 with: check: version model: gitflow @@ -94,7 +94,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - uses: BioJulia/Workflows@v1.3.1 + - uses: BioJulia/Workflows@v1.4.0 with: check: changelog model: gitflow @@ -121,7 +121,7 @@ jobs: with: delete-old-caches: false # Compares the main branch's public API and compat bounds with the head's, runs the main branch's tests against the head, and writes what it finds to the job summary. - - uses: BioJulia/Workflows@v1.3.1 + - uses: BioJulia/Workflows@v1.4.0 with: check: breaking model: gitflow @@ -147,7 +147,7 @@ jobs: with: delete-old-caches: false # Checks for a new entry under `## [Unreleased]`, and where the source changes, compares the public API with the base's: removed or changed API needs an entry under `### Breaking`, and added API one under `### Added`. - - uses: BioJulia/Workflows@v1.3.1 + - uses: BioJulia/Workflows@v1.4.0 with: check: entry model: gitflow diff --git a/.github/workflows/oneflow.yml b/.github/workflows/oneflow.yml index 3e07b7d..2b38961 100644 --- a/.github/workflows/oneflow.yml +++ b/.github/workflows/oneflow.yml @@ -53,7 +53,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - uses: BioJulia/Workflows@v1.3.1 + - uses: BioJulia/Workflows@v1.4.0 with: check: source model: oneflow @@ -73,7 +73,7 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - - uses: BioJulia/Workflows@v1.3.1 + - uses: BioJulia/Workflows@v1.4.0 with: check: version model: oneflow @@ -92,7 +92,7 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - - uses: BioJulia/Workflows@v1.3.1 + - uses: BioJulia/Workflows@v1.4.0 with: check: changelog model: oneflow @@ -119,7 +119,7 @@ jobs: - uses: julia-actions/cache@9353db09a0f2bbbdec6f41791918c75d37391b36 # v3.5.0 with: delete-old-caches: false - - uses: BioJulia/Workflows@v1.3.1 + - uses: BioJulia/Workflows@v1.4.0 with: check: breaking model: oneflow @@ -145,7 +145,7 @@ jobs: with: delete-old-caches: false # Checks for a new entry under `## [Unreleased]`, and where the source changes, compares the public API with the base's: removed or changed API needs an entry under `### Breaking`, and added API one under `### Added`. - - uses: BioJulia/Workflows@v1.3.1 + - uses: BioJulia/Workflows@v1.4.0 with: check: entry model: oneflow diff --git a/.github/workflows/register.yml b/.github/workflows/register.yml index 69a5845..5edf1dc 100644 --- a/.github/workflows/register.yml +++ b/.github/workflows/register.yml @@ -63,7 +63,7 @@ jobs: with: fetch-depth: 0 - id: registration - uses: BioJulia/Workflows@v1.3.1 + uses: BioJulia/Workflows@v1.4.0 with: check: registration project: ${{ inputs.project }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14f47c5..1d73b69 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -133,7 +133,7 @@ jobs: version=$(sed -n 's/^version = "\(.*\)"$/\1/p' "${RELEASEFLOW_PROJECT:+${RELEASEFLOW_PROJECT%/}/}Project.toml") git switch --detach "v$version" - id: prepare - uses: BioJulia/Workflows@v1.3.1 + uses: BioJulia/Workflows@v1.4.0 with: check: prepare model: ${{ inputs.model }} diff --git a/.github/workflows/trunk.yml b/.github/workflows/trunk.yml index b6bb861..c8a6455 100644 --- a/.github/workflows/trunk.yml +++ b/.github/workflows/trunk.yml @@ -59,7 +59,7 @@ jobs: with: fetch-depth: 0 - id: release - uses: BioJulia/Workflows@v1.3.1 + uses: BioJulia/Workflows@v1.4.0 with: check: is-release model: trunk @@ -77,7 +77,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - uses: BioJulia/Workflows@v1.3.1 + - uses: BioJulia/Workflows@v1.4.0 with: check: version model: trunk @@ -96,7 +96,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - uses: BioJulia/Workflows@v1.3.1 + - uses: BioJulia/Workflows@v1.4.0 with: check: changelog model: trunk @@ -123,7 +123,7 @@ jobs: - uses: julia-actions/cache@9353db09a0f2bbbdec6f41791918c75d37391b36 # v3.5.0 with: delete-old-caches: false - - uses: BioJulia/Workflows@v1.3.1 + - uses: BioJulia/Workflows@v1.4.0 with: check: breaking model: trunk @@ -150,7 +150,7 @@ jobs: with: delete-old-caches: false # Checks for a new entry under `## [Unreleased]`, and where the source changes, compares the public API with the base's: removed or changed API needs an entry under `### Breaking`, and added API one under `### Added`. - - uses: BioJulia/Workflows@v1.3.1 + - uses: BioJulia/Workflows@v1.4.0 with: check: entry model: trunk diff --git a/ReleaseFlow/CHANGELOG.md b/ReleaseFlow/CHANGELOG.md index 0225462..4b19597 100644 --- a/ReleaseFlow/CHANGELOG.md +++ b/ReleaseFlow/CHANGELOG.md @@ -6,6 +6,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +## [1.4.0] - 2026-11-03 + ### Added - The back-merge workflow's input `auto-merge` approves and merges a release's back-merge once TagBot has tagged the release at the commit that the back-merge brings, its required checks pass, and it doesn't conflict, on the caller's `workflow_run` of TagBot and of the checks, without GitHub's auto-merge; each run says what each back-merge waits for, and which setting or permission is missing. @@ -100,7 +102,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - The back-merge workflow opens its pull request with a GitHub App's token, from the secrets `RELEASEFLOW_APP_CLIENT_ID` and `RELEASEFLOW_APP_PRIVATE_KEY`, in preference to `RELEASEFLOW_TOKEN`; the README explains how to create the app. - The back-merge workflow creates its branch with the job's own token, so the app or personal token needs only read and write access to pull requests; where GitHub refuses the job's token the branch, the other token creates it, with access to contents and workflows. -[Unreleased]: https://github.com/BioJulia/Workflows/compare/v1.3.1...HEAD +[Unreleased]: https://github.com/BioJulia/Workflows/compare/v1.4.0...HEAD +[1.4.0]: https://github.com/BioJulia/Workflows/compare/v1.3.1...v1.4.0 [1.3.1]: https://github.com/BioJulia/Workflows/compare/v1.3.0...v1.3.1 [1.3.0]: https://github.com/BioJulia/Workflows/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/BioJulia/Workflows/compare/v1.1.1...v1.2.0 diff --git a/ReleaseFlow/Project.toml b/ReleaseFlow/Project.toml index 45cb921..708d201 100644 --- a/ReleaseFlow/Project.toml +++ b/ReleaseFlow/Project.toml @@ -1,6 +1,6 @@ name = "ReleaseFlow" uuid = "0b38582b-4297-4346-83b3-5ff614b8a115" -version = "1.3.1" +version = "1.4.0" authors = ["BioJulia"] [deps]