|
| 1 | +# Backports one or more commits onto older release branches by cherry-pick and |
| 2 | +# opens a PR per target branch. |
| 3 | +# |
| 4 | +# Release lines in this repo are `major.minor` branches (e.g. 12.0, 11.4, ... 3.0); |
| 5 | +# `master` is the tip. Backporting = cherry-picking a landed commit onto those older |
| 6 | +# branches. This workflow is manual (workflow_dispatch) on purpose: the operator picks |
| 7 | +# the exact commits and targets, and nothing about the source PR signals that a |
| 8 | +# (possibly embargoed) backport is in flight. |
| 9 | +# |
| 10 | +# Notes on the two required PR checks (see pr-checks.yml): |
| 11 | +# - semantic-pull-request: the PR title must be a conventional-commit string. We take |
| 12 | +# it from the source commit's subject, which is already semantic, so this passes. |
| 13 | +# - changelog-enforcer: the backport adds a real `[X.Y.Z]` CHANGELOG section in a |
| 14 | +# separate release commit, so the enforcer passes on its own. The `skip_changelog` |
| 15 | +# input stays only as an escape hatch (default off). |
| 16 | +# |
| 17 | +# Per target branch the result is TWO stacked commits: the cherry-pick(s), then a single |
| 18 | +# `chore` release commit that bumps build.gradle and inserts the changelog entry. Only |
| 19 | +# patches are backported, so the version bump is always a patch increment of that line. |
| 20 | +# |
| 21 | +# Auth: uses secrets.GH_TOKEN (a PAT). GITHUB_TOKEN-created PRs do NOT trigger further |
| 22 | +# workflows, so backport PRs would open with no CI — the PAT avoids that. |
| 23 | + |
| 24 | +name: Backport |
| 25 | + |
| 26 | +on: |
| 27 | + workflow_dispatch: |
| 28 | + inputs: |
| 29 | + commits: |
| 30 | + description: "Commit SHA(s) to cherry-pick, in order (space- or comma-separated). Leave blank to use 'pr'." |
| 31 | + required: false |
| 32 | + type: string |
| 33 | + pr: |
| 34 | + description: "PR number to backport (resolves to its merge/squash commit). Ignored when 'commits' is set." |
| 35 | + required: false |
| 36 | + type: string |
| 37 | + target_branches: |
| 38 | + description: "Target release branches, comma-separated. e.g. 11.4,11.3,11.2" |
| 39 | + required: true |
| 40 | + type: string |
| 41 | + draft: |
| 42 | + description: "Open the backport PRs as drafts." |
| 43 | + type: boolean |
| 44 | + default: false |
| 45 | + skip_changelog: |
| 46 | + description: "Also attach the Skip-Changelog label (normally unneeded — the backport writes a real CHANGELOG entry)." |
| 47 | + type: boolean |
| 48 | + default: false |
| 49 | + |
| 50 | +permissions: |
| 51 | + contents: write |
| 52 | + pull-requests: write |
| 53 | + |
| 54 | +jobs: |
| 55 | + prepare: |
| 56 | + runs-on: ubuntu-latest |
| 57 | + outputs: |
| 58 | + targets: ${{ steps.parse.outputs.targets }} |
| 59 | + commits: ${{ steps.resolve.outputs.commits }} |
| 60 | + steps: |
| 61 | + - uses: actions/checkout@v4 |
| 62 | + with: |
| 63 | + fetch-depth: 0 |
| 64 | + token: ${{ secrets.GH_TOKEN }} |
| 65 | + |
| 66 | + - id: parse |
| 67 | + name: Parse target branches into a JSON matrix |
| 68 | + run: | |
| 69 | + set -euo pipefail |
| 70 | + raw='${{ inputs.target_branches }}' |
| 71 | + json=$(printf '%s' "$raw" | tr ', ' '\n' | grep -v '^$' | jq -R . | jq -s -c .) |
| 72 | + if [ "$json" = "[]" ]; then |
| 73 | + echo "::error::No target branches provided."; exit 1 |
| 74 | + fi |
| 75 | + echo "targets=$json" >> "$GITHUB_OUTPUT" |
| 76 | + echo "Targets: $json" |
| 77 | +
|
| 78 | + - id: resolve |
| 79 | + name: Resolve commits (explicit 'commits' or from 'pr') |
| 80 | + env: |
| 81 | + GH_TOKEN: ${{ secrets.GH_TOKEN }} |
| 82 | + run: | |
| 83 | + set -euo pipefail |
| 84 | + if [ -n '${{ inputs.commits }}' ]; then |
| 85 | + commits=$(printf '%s' '${{ inputs.commits }}' | tr ',' ' ' | xargs) |
| 86 | + elif [ -n '${{ inputs.pr }}' ]; then |
| 87 | + # Prefer the squash/merge commit; fall back to the PR's individual commits. |
| 88 | + merge=$(gh pr view '${{ inputs.pr }}' --json mergeCommit -q '.mergeCommit.oid' 2>/dev/null || true) |
| 89 | + if [ -n "$merge" ] && [ "$merge" != "null" ]; then |
| 90 | + commits="$merge" |
| 91 | + else |
| 92 | + commits=$(gh pr view '${{ inputs.pr }}' --json commits -q '.commits[].oid' | xargs) |
| 93 | + fi |
| 94 | + else |
| 95 | + echo "::error::Provide either 'commits' or 'pr'."; exit 1 |
| 96 | + fi |
| 97 | + if [ -z "$commits" ]; then echo "::error::Could not resolve any commits."; exit 1; fi |
| 98 | + echo "commits=$commits" >> "$GITHUB_OUTPUT" |
| 99 | + echo "Commits to cherry-pick (in order): $commits" |
| 100 | +
|
| 101 | + backport: |
| 102 | + needs: prepare |
| 103 | + runs-on: ubuntu-latest |
| 104 | + strategy: |
| 105 | + # One conflicting target must not abort the others. |
| 106 | + fail-fast: false |
| 107 | + matrix: |
| 108 | + target: ${{ fromJson(needs.prepare.outputs.targets) }} |
| 109 | + steps: |
| 110 | + - uses: actions/checkout@v4 |
| 111 | + with: |
| 112 | + fetch-depth: 0 |
| 113 | + token: ${{ secrets.GH_TOKEN }} |
| 114 | + |
| 115 | + - name: Configure git identity |
| 116 | + run: | |
| 117 | + git config user.name "supertokens-backport-bot" |
| 118 | + git config user.email "backport-bot@supertokens.io" |
| 119 | +
|
| 120 | + - name: Cherry-pick onto ${{ matrix.target }}, bump patch version, open a PR |
| 121 | + env: |
| 122 | + GH_TOKEN: ${{ secrets.GH_TOKEN }} |
| 123 | + TARGET: ${{ matrix.target }} |
| 124 | + COMMITS: ${{ needs.prepare.outputs.commits }} |
| 125 | + DRAFT: ${{ inputs.draft }} |
| 126 | + SKIP_CHANGELOG: ${{ inputs.skip_changelog }} |
| 127 | + ACTOR: ${{ github.actor }} |
| 128 | + run: | |
| 129 | + set -euo pipefail |
| 130 | +
|
| 131 | + # 1. The target branch must actually exist upstream. |
| 132 | + if ! git ls-remote --exit-code --heads origin "$TARGET" >/dev/null 2>&1; then |
| 133 | + echo "::error::Target branch '$TARGET' does not exist on origin." |
| 134 | + echo "### ❌ \`$TARGET\`: no such release branch" >> "$GITHUB_STEP_SUMMARY" |
| 135 | + exit 1 |
| 136 | + fi |
| 137 | + git fetch --no-tags origin "$TARGET" |
| 138 | +
|
| 139 | + # 2. Fresh backport branch off the target tip (deterministic name -> re-runnable). |
| 140 | + first_sha=$(printf '%s' "$COMMITS" | awk '{print $1}') |
| 141 | + short=$(git rev-parse --short "$first_sha") |
| 142 | + bp_branch="backport/$TARGET/$short" |
| 143 | + git checkout -B "$bp_branch" "origin/$TARGET" |
| 144 | +
|
| 145 | + # 3. Cherry-pick the backported commit(s). -x records the source SHA. |
| 146 | + if ! git cherry-pick -x $COMMITS; then |
| 147 | + git cherry-pick --abort || true |
| 148 | + { |
| 149 | + echo "### ⚠️ Backport to \`$TARGET\` hit conflicts — resolve manually" |
| 150 | + echo "" |
| 151 | + echo '```bash' |
| 152 | + echo "git fetch origin $TARGET && git checkout -b $bp_branch origin/$TARGET" |
| 153 | + echo "git cherry-pick -x $COMMITS" |
| 154 | + echo "# resolve conflicts, bump the patch version + changelog by hand, then:" |
| 155 | + echo "git push origin $bp_branch" |
| 156 | + echo '```' |
| 157 | + } >> "$GITHUB_STEP_SUMMARY" |
| 158 | + echo "::error::Cherry-pick onto $TARGET conflicted; see the job summary." |
| 159 | + exit 1 |
| 160 | + fi |
| 161 | +
|
| 162 | + # 4. Compute the next PATCH version from the target line's build.gradle. |
| 163 | + # Only patches are backported, so a patch increment is always correct. |
| 164 | + cur=$(grep -m1 -E '^version[[:space:]]*=' build.gradle | sed -E 's/.*"([^"]+)".*/\1/') |
| 165 | + base=${cur%%-*} # drop any -canary / pre-release suffix |
| 166 | + IFS='.' read -r MA MI PA <<< "$base" |
| 167 | + if [ "$MA.$MI" != "$TARGET" ]; then |
| 168 | + echo "::error::build.gradle version ($cur) is not on the $TARGET line; refusing to guess a version." |
| 169 | + exit 1 |
| 170 | + fi |
| 171 | + new_ver="$MA.$MI.$((PA + 1))" |
| 172 | + today=$(date -u +%Y-%m-%d) |
| 173 | + echo "Next patch on $TARGET: $cur -> $new_ver" |
| 174 | +
|
| 175 | + # 5. Bump the first top-level `version = "..."` line in build.gradle. |
| 176 | + sed -i -E "0,/^version[[:space:]]*=.*/s//version = \"$new_ver\"/" build.gradle |
| 177 | +
|
| 178 | + # 6. Insert a CHANGELOG section above the newest existing versioned entry. |
| 179 | + # Bullets = backported commit subjects, minus the conventional-commit prefix. |
| 180 | + { |
| 181 | + echo "## [$new_ver] - $today" |
| 182 | + echo "" |
| 183 | + for sha in $COMMITS; do |
| 184 | + git log -1 --format=%s "$sha" | sed -E 's/^[a-z]+(\([^)]*\))?!?:[[:space:]]*//; s/^/- /' |
| 185 | + done |
| 186 | + echo "" |
| 187 | + } > /tmp/entry.md |
| 188 | + awk ' |
| 189 | + !ins && /^## \[[0-9]/ { while ((getline l < "/tmp/entry.md") > 0) print l; close("/tmp/entry.md"); ins=1 } |
| 190 | + { print } |
| 191 | + END { if (!ins) { while ((getline l < "/tmp/entry.md") > 0) print l } } |
| 192 | + ' CHANGELOG.md > CHANGELOG.tmp && mv CHANGELOG.tmp CHANGELOG.md |
| 193 | +
|
| 194 | + # 7. The version bump + changelog is a SEPARATE commit, stacked on the backport. |
| 195 | + git add build.gradle CHANGELOG.md |
| 196 | + git commit -m "chore: bump version to $new_ver and update changelog" |
| 197 | +
|
| 198 | + # 8. Push (backport commit[s] + the release commit). |
| 199 | + git push --force-with-lease origin "$bp_branch" |
| 200 | +
|
| 201 | + # 9. Open the PR. Title comes from the source subject (already semantic). |
| 202 | + subject=$(git log -1 --format=%s "$first_sha") |
| 203 | + labels="backport" |
| 204 | + [ "$SKIP_CHANGELOG" = "true" ] && labels="$labels,Skip-Changelog" |
| 205 | + # Best-effort: make sure the labels exist (ignore failures / insufficient perms). |
| 206 | + for l in $(printf '%s' "$labels" | tr ',' ' '); do |
| 207 | + gh label create "$l" >/dev/null 2>&1 || true |
| 208 | + done |
| 209 | +
|
| 210 | + cat > /tmp/backport-body.md <<EOF |
| 211 | + Automated backport of \`$COMMITS\` onto \`$TARGET\`, released as **$new_ver**. |
| 212 | +
|
| 213 | + - Cherry-picked with \`-x\` — each commit message carries its source SHA. |
| 214 | + - A separate \`chore\` commit bumps \`build.gradle\` to \`$new_ver\` and adds the \`[$new_ver]\` CHANGELOG section. |
| 215 | +
|
| 216 | + Triggered from **${{ github.workflow }}** by @$ACTOR. |
| 217 | +
|
| 218 | + > Reviewer: confirm the generated CHANGELOG bullets read well for the \`$TARGET\` line. |
| 219 | + EOF |
| 220 | +
|
| 221 | + draft_flag="" |
| 222 | + [ "$DRAFT" = "true" ] && draft_flag="--draft" |
| 223 | +
|
| 224 | + gh pr create \ |
| 225 | + --base "$TARGET" \ |
| 226 | + --head "$bp_branch" \ |
| 227 | + --title "$subject (backport to $TARGET)" \ |
| 228 | + --body-file /tmp/backport-body.md \ |
| 229 | + --label "$labels" \ |
| 230 | + $draft_flag |
| 231 | +
|
| 232 | + echo "### ✅ \`$TARGET\`: backport PR opened as $new_ver (\`$bp_branch\`)" >> "$GITHUB_STEP_SUMMARY" |
0 commit comments