From c687b439d6f50827017f0bb690970d1093bc3be7 Mon Sep 17 00:00:00 2001 From: Pascal Zimmermann Date: Fri, 3 Jul 2026 11:37:18 +0200 Subject: [PATCH 1/4] feat: Add label-triggered backport workflow Adds a GitHub Actions workflow that automatically cherry-picks merged PRs onto other distro branches when backport/* labels are present. Usage: label a PR with backport/ubuntu-noble, backport/ubuntu-jammy, or backport/ubuntu-resolute before merging. The workflow opens a ready-for-review PR when the cherry-pick applies cleanly, or a draft PR with the failing commit noted when there are conflicts. Signed-off-by: Pascal Zimmermann --- .github/workflows/backport.yml | 151 +++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 .github/workflows/backport.yml diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml new file mode 100644 index 0000000000..52a1160e0b --- /dev/null +++ b/.github/workflows/backport.yml @@ -0,0 +1,151 @@ +name: Backport + +on: + pull_request: + types: [closed] + +permissions: + contents: write + pull-requests: write + +jobs: + build-matrix: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + outputs: + targets: ${{ steps.labels.outputs.targets }} + steps: + - id: labels + name: Extract backport targets from labels + env: + LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }} + run: | + targets='[]' + targets=$(echo "$LABELS" | jq -c '[.[] | select(startswith("backport/")) | ltrimstr("backport/")]' || echo '[]') + echo "targets=$targets" >> "$GITHUB_OUTPUT" + + backport: + needs: build-matrix + if: needs.build-matrix.outputs.targets != '' && needs.build-matrix.outputs.targets != '[]' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: ${{ fromJson(needs.build-matrix.outputs.targets) }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Check if backport branch already exists + id: check + run: | + branch="backport/${{ github.event.pull_request.number }}-to-${{ matrix.target }}" + if git ls-remote --exit-code origin "refs/heads/$branch" > /dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + echo "branch=$branch" >> "$GITHUB_OUTPUT" + + - name: Cherry-pick onto target branch + if: steps.check.outputs.exists == 'false' + id: cherrypick + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + TARGET: ${{ matrix.target }} + BRANCH: ${{ steps.check.outputs.branch }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + git fetch origin "$TARGET" + git checkout -b "$BRANCH" "origin/$TARGET" + + # Detect squash vs merge commit by parent count + parent_count=$(git log --pretty=format:'%P' -n 1 "$MERGE_SHA" | wc -w | tr -d ' ') + + if [ "$parent_count" -gt 1 ]; then + mapfile -t commits < <(git log --reverse --pretty=format:'%H' "$BASE_SHA..$MERGE_SHA") + else + commits=("$MERGE_SHA") + fi + + if [ "${#commits[@]}" -eq 0 ]; then + echo "No commits to cherry-pick for PR #$PR_NUMBER onto $TARGET — skipping." + exit 0 + fi + + conflict=false + failed_sha="" + for sha in "${commits[@]}"; do + if ! git cherry-pick "$sha"; then + git cherry-pick --abort + conflict=true + failed_sha="$sha" + # Add a placeholder commit so the branch has content for the draft PR + git commit --allow-empty -m "backport: conflict on $sha — resolve manually" + break + fi + done + + git push origin "$BRANCH" + echo "conflict=$conflict" >> "$GITHUB_OUTPUT" + echo "failed_sha=$failed_sha" >> "$GITHUB_OUTPUT" + + - name: Open backport PR (clean) + if: steps.check.outputs.exists == 'false' && steps.cherrypick.outputs.conflict == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + TARGET: ${{ matrix.target }} + BRANCH: ${{ steps.check.outputs.branch }} + run: | + body_file=$(mktemp) + printf 'Automated backport of #%s to `%s`.\n\nOriginal PR: %s\n' \ + "$PR_NUMBER" "$TARGET" "$PR_URL" > "$body_file" + + gh pr create \ + --title "[Backport $TARGET] $PR_TITLE" \ + --body-file "$body_file" \ + --base "$TARGET" \ + --head "$BRANCH" + + rm -f "$body_file" + + - name: Open backport PR (conflict — draft) + if: steps.check.outputs.exists == 'false' && steps.cherrypick.outputs.conflict == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + TARGET: ${{ matrix.target }} + BRANCH: ${{ steps.check.outputs.branch }} + FAILED_SHA: ${{ steps.cherrypick.outputs.failed_sha }} + run: | + body_file=$(mktemp) + printf 'Automated backport of #%s to `%s`.\n\nOriginal PR: %s\n\n> [!WARNING]\n> **This backport has conflicts.** The cherry-pick of commit `%s` could not be applied cleanly.\n> Check out the branch `%s`, resolve the conflicts, and mark this PR as ready for review.\n' \ + "$PR_NUMBER" "$TARGET" "$PR_URL" "$FAILED_SHA" "$BRANCH" > "$body_file" + + gh pr create \ + --title "[Backport $TARGET] $PR_TITLE" \ + --body-file "$body_file" \ + --base "$TARGET" \ + --head "$BRANCH" \ + --draft + + rm -f "$body_file" + + - name: Skip (branch already exists) + if: steps.check.outputs.exists == 'true' + run: | + echo "Backport branch ${{ steps.check.outputs.branch }} already exists — skipping." From ea241df8c10e821f95da41baf2d03e5f2cd86a9a Mon Sep 17 00:00:00 2001 From: Pascal Zimmermann Date: Fri, 3 Jul 2026 11:46:59 +0200 Subject: [PATCH 2/4] docs: Update CONTRIBUTING and PR template for label-triggered backports Signed-off-by: Pascal Zimmerman # Conflicts: # .github/pull_request_template.md --- .github/pull_request_template.md | 15 +++++++----- CONTRIBUTING.md | 39 +++++++++++++++++++++++++++----- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 37e17ddd2d..ff84c3932a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,10 +1,13 @@ -NOTE: this repository uses a "Merge Forward" strategy +## Backporting -Changes should be made in the earliest applicable branch, and -merged forward through subsequent branches. -1. PR should be created against the oldest stemcell branch, ex: `ubuntu-` -2. After this PR has been merged create a PR to merge `ubuntu-` into `ubuntu-` -3. Repeat as needed for subsequent stemcell line branches +To backport this change to other stemcell branches, add one or more labels +before merging and the workflow will open cherry-pick PRs automatically: + +- `backport/ubuntu-jammy` +- `backport/ubuntu-noble` +- `backport/ubuntu-resolute` + +See [CONTRIBUTING.md](../CONTRIBUTING.md) for details. ### AI Review Feedback diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a75194451..ae9f7c85d2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,14 +1,41 @@ # Contributing to BOSH Linux Stemcell Builder -**NOTE:** Please ensure that changes are made to the earliest supported branch -to which the change should apply. The change should then be merged forward to -all other supported branches. - -Branches are names for the Ubuntu release on which they are based. For example -an "Ubuntu SHORT_NAME" based stemcell will be on the branch: +Branches are named for the Ubuntu release on which they are based: - `ubuntu-` As of `2026-06-09` the following stemcell lines / branches are supported: - Ubuntu Jammy / `ubuntu-jammy` - Ubuntu Noble / `ubuntu-noble` - Ubuntu Resolute / `ubuntu-resolute` + +## Backporting Changes Across Branches + +When a change should apply to more than one stemcell line, open your PR against +the earliest applicable branch and use **backport labels** to request automatic +cherry-picks onto other branches. + +### How to backport + +1. Open your PR against the oldest applicable branch (e.g. `ubuntu-jammy`). +2. Add one or more backport labels before merging: + + | Label | Target branch | + |---|---| + | `backport/ubuntu-jammy` | `ubuntu-jammy` | + | `backport/ubuntu-noble` | `ubuntu-noble` | + | `backport/ubuntu-resolute` | `ubuntu-resolute` | + +3. Merge the PR. The backport workflow opens a new PR for each labeled target + automatically, titled `[Backport ] `. + +### Clean vs. conflict backports + +- **Clean cherry-pick** — the backport PR is opened ready for review. +- **Conflict** — the backport PR is opened as a draft. The PR body names the + failing commit. Check out the branch, resolve the conflicts, and mark the PR + ready for review. + +### Re-runs + +The workflow is idempotent: if the backport branch already exists (e.g. after a +failed run), re-running the workflow skips that target silently. From f189a86bc13658ad3e979c93d16ee9d7e71cb8a8 Mon Sep 17 00:00:00 2001 From: Pascal Zimmermann Date: Fri, 3 Jul 2026 14:04:52 +0200 Subject: [PATCH 3/4] *fix: Adjust the GH workflow permissions *fix: Add concurrency group keyed on PR number to prevent push races on re-runs (concurrency-limits) *fix: Move matrix.target and PR number out of run: template strings into env: vars in the check and skip steps (template-injection) Signed-off-by: Pascal Zimmermann --- .github/workflows/backport.yml | 48 ++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 52a1160e0b..959fd9628c 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -4,9 +4,13 @@ on: pull_request: types: [closed] -permissions: - contents: write - pull-requests: write +# Serialize runs for the same PR to prevent branch-push races on re-runs +concurrency: + group: backport-${{ github.event.pull_request.number }} + cancel-in-progress: false + +# Default: no permissions; write access is granted only to the backport job +permissions: {} jobs: build-matrix: @@ -28,6 +32,10 @@ jobs: needs: build-matrix if: needs.build-matrix.outputs.targets != '' && needs.build-matrix.outputs.targets != '[]' runs-on: ubuntu-latest + # Scoped to only the job that pushes branches and opens PRs + permissions: + contents: write + pull-requests: write strategy: fail-fast: false matrix: @@ -43,16 +51,28 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - - name: Check if backport branch already exists + - name: Check if backport branch or PR already exists id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + TARGET: ${{ matrix.target }} run: | - branch="backport/${{ github.event.pull_request.number }}-to-${{ matrix.target }}" + branch="backport/${PR_NUMBER}-to-${TARGET}" + echo "branch=$branch" >> "$GITHUB_OUTPUT" + if git ls-remote --exit-code origin "refs/heads/$branch" > /dev/null 2>&1; then - echo "exists=true" >> "$GITHUB_OUTPUT" + # Branch exists — check whether a PR was also successfully created + pr_state=$(gh pr list --head "$branch" --state all --json state --jq '.[0].state // "NONE"') + if [ "$pr_state" = "NONE" ]; then + # Branch pushed but PR creation failed — proceed to open the PR + echo "exists=branch_only" >> "$GITHUB_OUTPUT" + else + echo "exists=true" >> "$GITHUB_OUTPUT" + fi else echo "exists=false" >> "$GITHUB_OUTPUT" fi - echo "branch=$branch" >> "$GITHUB_OUTPUT" - name: Cherry-pick onto target branch if: steps.check.outputs.exists == 'false' @@ -100,7 +120,9 @@ jobs: echo "failed_sha=$failed_sha" >> "$GITHUB_OUTPUT" - name: Open backport PR (clean) - if: steps.check.outputs.exists == 'false' && steps.cherrypick.outputs.conflict == 'false' + if: > + (steps.check.outputs.exists == 'false' || steps.check.outputs.exists == 'branch_only') && + steps.cherrypick.outputs.conflict == 'false' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} @@ -122,7 +144,9 @@ jobs: rm -f "$body_file" - name: Open backport PR (conflict — draft) - if: steps.check.outputs.exists == 'false' && steps.cherrypick.outputs.conflict == 'true' + if: > + (steps.check.outputs.exists == 'false' || steps.check.outputs.exists == 'branch_only') && + steps.cherrypick.outputs.conflict == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} @@ -145,7 +169,9 @@ jobs: rm -f "$body_file" - - name: Skip (branch already exists) + - name: Skip (backport PR already exists) if: steps.check.outputs.exists == 'true' + env: + BRANCH: ${{ steps.check.outputs.branch }} run: | - echo "Backport branch ${{ steps.check.outputs.branch }} already exists — skipping." + echo "Backport branch ${BRANCH} already exists with a PR — skipping." From 5d1d41fdb7eeaf4fee2c11054265d38f3afc8663 Mon Sep 17 00:00:00 2001 From: Pascal Zimmermann Date: Fri, 3 Jul 2026 17:32:36 +0200 Subject: [PATCH 4/4] * fix: The branch_only recovery path was dead code: cherry-pick step only ran for exists==false, so cherrypick outputs were never set for the recovery case and the PR-open steps never fired. Now runs for branch_only too * fix: Merge commit included in cherry-pick range: git log BASE..MERGE on a merge commit includes the merge commit itself, which cherry-pick rejects without -m. Add --no-merges to exclude it * docs: Satisfy markdownlint Signed-off-by: Pascal Zimmermann --- .github/pull_request_template.md | 2 +- .github/workflows/backport.yml | 26 +++++++++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ff84c3932a..cd5a5bf9b7 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,4 +1,4 @@ -## Backporting +# Backporting To backport this change to other stemcell branches, add one or more labels before merging and the workflow will open cherry-pick PRs automatically: diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 959fd9628c..35640042c0 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -75,7 +75,7 @@ jobs: fi - name: Cherry-pick onto target branch - if: steps.check.outputs.exists == 'false' + if: steps.check.outputs.exists == 'false' || steps.check.outputs.exists == 'branch_only' id: cherrypick env: PR_NUMBER: ${{ github.event.pull_request.number }} @@ -84,15 +84,35 @@ jobs: BRANCH: ${{ steps.check.outputs.branch }} MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} BASE_SHA: ${{ github.event.pull_request.base.sha }} + EXISTS: ${{ steps.check.outputs.exists }} run: | + # Recovery path: branch was pushed but PR creation failed on a previous + # run. Inspect the tip commit to determine conflict state and skip the + # cherry-pick entirely — the branch content is already correct. + if [ "$EXISTS" = "branch_only" ]; then + git fetch origin "$BRANCH" + last_msg=$(git log -1 --pretty=%B "origin/$BRANCH") + if printf '%s' "$last_msg" | grep -q '^backport: conflict on '; then + failed_sha=$(printf '%s' "$last_msg" | sed -n 's/^backport: conflict on \([0-9a-f]*\).*/\1/p') + echo "conflict=true" >> "$GITHUB_OUTPUT" + echo "failed_sha=$failed_sha" >> "$GITHUB_OUTPUT" + else + echo "conflict=false" >> "$GITHUB_OUTPUT" + echo "failed_sha=" >> "$GITHUB_OUTPUT" + fi + exit 0 + fi + git fetch origin "$TARGET" git checkout -b "$BRANCH" "origin/$TARGET" - # Detect squash vs merge commit by parent count + # Detect squash vs merge commit by parent count. + # For merge commits use --no-merges to exclude the merge commit itself + # from the list — cherry-picking a merge commit without -m fails. parent_count=$(git log --pretty=format:'%P' -n 1 "$MERGE_SHA" | wc -w | tr -d ' ') if [ "$parent_count" -gt 1 ]; then - mapfile -t commits < <(git log --reverse --pretty=format:'%H' "$BASE_SHA..$MERGE_SHA") + mapfile -t commits < <(git log --reverse --no-merges --pretty=format:'%H' "$BASE_SHA..$MERGE_SHA") else commits=("$MERGE_SHA") fi