From 2aa7e947e4f255bb3df7ee5540a06cc3e1ca22d5 Mon Sep 17 00:00:00 2001 From: paulohenriquevn Date: Fri, 4 Sep 2026 22:56:38 -0300 Subject: [PATCH] feat(backmerge): share the back-merge, and decide its mechanism by measurement Three repositories had a back-merge and two mechanisms: theokit pushed straight to workspace, theokit-sdk and theokit-gateways opened a pull request. Three instances of one job under two file names, which is also why a grep for one of them reported two rather than three. The objection that had left this undecided -- that a direct push bypasses review -- is false. Measured 2026-09-05: workspace carries no branch protection in ANY of the ten consumers, so neither mechanism bypasses anything. What decided it was the opposite worry, also measured. A pull request only helps if somebody merges it. Every back-merge PR in both repositories was merged, all but one on the day it was opened (sdk #511 #516 #519 #528 #540 #548; gateways #108 #111 #114 #117 #120 #121). The failure mode did not happen, so the pull request keeps its advantage -- a reviewable record -- at no observed cost. One input, target-branch, defaulting to workspace: every current caller uses it, and a repository with a second permanent branch calls this again rather than growing a matrix here. actionlint and zizmor clean. --- .github/workflows/backmerge.yml | 111 ++++++++++++++++++++++++++++++++ CHANGELOG.md | 20 ++++++ 2 files changed, 131 insertions(+) create mode 100644 .github/workflows/backmerge.yml diff --git a/.github/workflows/backmerge.yml b/.github/workflows/backmerge.yml new file mode 100644 index 0000000..9a78077 --- /dev/null +++ b/.github/workflows/backmerge.yml @@ -0,0 +1,111 @@ +# Opens a pull request back-merging `main` into a long-lived branch after a release. +# +# Called as: +# +# jobs: +# backmerge: +# uses: usetheokit/shared-workflows/.github/workflows/backmerge.yml@v1 +# permissions: +# contents: read +# pull-requests: write +# +# WHY IT EXISTS. A release bumps versions and deletes the consumed `.changeset/*.md` on `main`, +# and nothing carries either back. So a long-lived branch falls one release further behind on +# every cut, silently, and the next promotion cut from that state re-releases what already +# shipped. +# +# WHY A PULL REQUEST AND NOT A PUSH, decided by measurement rather than by preference. Three +# repositories had a back-merge and two mechanisms: `theokit` pushed straight to `workspace`, +# `theokit-sdk` and `theokit-gateways` opened a pull request. The obvious objection to the push — +# that it bypasses review — turned out to be false: measured 2026-09-05, `workspace` carries NO +# branch protection in any of the ten consumers, so neither mechanism bypasses anything. +# +# What decided it was the opposite worry, also measured. A pull request only helps if somebody +# merges it, and an unmerged one leaves the branch exactly as far behind as no mechanism at all. +# Every back-merge pull request in both repositories was merged, and all but one on the day it was +# opened (`theokit-sdk` #511, #516, #519, #528, #540, #548; `theokit-gateways` #108, #111, #114, +# #117, #120, #121). The failure mode did not happen, so the pull request keeps its advantage — a +# reviewable record of what came back — at no observed cost. +# +# The push mechanism is not wrong. It is simply the one with no audit trail, and with two working +# options the one that leaves a record is the one worth sharing. +name: Back-merge + +on: + workflow_call: + inputs: + target-branch: + description: >- + The long-lived branch that must not fall behind `main`. Every current caller uses + `workspace`, which is where work is born; a repository with a second permanent branch + calls this again with a different value rather than growing a matrix here. + type: string + required: false + default: workspace + +# One at a time: two pushes to main in quick succession would otherwise race to open the same +# pull request. +concurrency: backmerge-${{ inputs.target-branch }}-${{ github.ref }} + +permissions: + contents: read + pull-requests: write + +jobs: + backmerge: + name: main → ${{ inputs.target-branch }} + runs-on: ubuntu-latest + # Two git counts and at most one `gh pr create`. A tight ceiling turns a hang into a fast, + # obvious failure instead of a runner held for GitHub's six-hour default. + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The counts below compare two branches, which needs their histories. + fetch-depth: 0 + # The pull request is opened through `gh`, authenticated from GH_TOKEN in the step + # below. Nothing here uses the git credential, so it does not stay in `.git/config`. + persist-credentials: false + + - name: Open or report the back-merge pull request + env: + GH_TOKEN: ${{ github.token }} + TARGET: ${{ inputs.target-branch }} + run: | + set -euo pipefail + + behind="$(git rev-list --count "origin/${TARGET}..origin/main")" + echo "${TARGET} is ${behind} commit(s) behind main" + + # The steady state. Saying so explicitly keeps a green run from being read as "the job + # did not run" — a distinction this ecosystem keeps paying for elsewhere. + if [ "${behind}" -eq 0 ]; then + echo "✓ nothing to back-merge" + exit 0 + fi + + existing="$(gh pr list --base "${TARGET}" --head main --state open --json number \ + --jq '.[0].number // empty')" + if [ -n "${existing}" ]; then + echo "✓ PR #${existing} is already open and will pick these commits up" + exit 0 + fi + + # `workspace` is a LIVE checkout, unlike `develop`. Opening the pull request touches no + # working tree, but whoever merges it may need a clean one, and saying so in the body + # costs a line and saves a confusing refusal. + note="" + if [ "${TARGET}" = "workspace" ]; then + note=$'\n\n`workspace` is where work is born and is usually checked out somewhere with\nuncommitted changes. Opening this pull request touches nothing; merging it may need a clean tree.' + fi + + gh pr create \ + --base "${TARGET}" \ + --head main \ + --title "chore: back-merge main into ${TARGET}" \ + --body "Opened automatically after a push to \`main\`. + + The release bump and the consumed \`.changeset/*.md\` deletions live only on \`main\` until + this lands. While it is open, \`${TARGET}\` still declares the previous version and still + holds changesets that have already shipped — so a promotion cut from that state would + re-release them.${note}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 120e8ee..fa22f1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`backmerge.yml` is now a reusable workflow, and which mechanism it shares was decided by + measurement (#53).** Three repositories had a back-merge and two mechanisms: `theokit` pushed + straight to `workspace`, `theokit-sdk` and `theokit-gateways` opened a pull request. Three + instances of one job under two names (`backmerge.yml` and `release-backmerge.yml`), which is + also why a grep for one of them reported two rather than three. + + The obvious objection to the push — that it bypasses review — is **false**. Measured + 2026-09-05: `workspace` carries no branch protection in ANY of the ten consumers, so neither + mechanism bypasses anything. That was the reason this had been left undecided, and it did not + survive being checked. + + What decided it was the opposite worry, also measured. A pull request only helps if somebody + merges it, and an unmerged one leaves the branch exactly as far behind as no mechanism at all. + Every back-merge pull request in both repositories was merged, all but one on the day it was + opened. The failure mode did not happen, so the pull request keeps its advantage — a reviewable + record of what came back — at no observed cost. + + The push mechanism is not wrong. With two working options, the one that leaves a record is the + one worth sharing. + - **`actions/setup` does NOT accept `registry-url`, and now says why (#54, corrected).** The entry this replaces claimed that seven of the nine `release.yml` pass `registry-url` and that its absence was what kept them off this action. **Both halves were false.** The grep behind the