diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 37e17ddd2d..cd5a5bf9b7 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/.github/workflows/backport.yml b/.github/workflows/backport.yml new file mode 100644 index 0000000000..35640042c0 --- /dev/null +++ b/.github/workflows/backport.yml @@ -0,0 +1,197 @@ +name: Backport + +on: + pull_request: + types: [closed] + +# 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: + 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 + # Scoped to only the job that pushes branches and opens PRs + permissions: + contents: write + pull-requests: write + 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 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/${PR_NUMBER}-to-${TARGET}" + echo "branch=$branch" >> "$GITHUB_OUTPUT" + + if git ls-remote --exit-code origin "refs/heads/$branch" > /dev/null 2>&1; then + # 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 + + - name: Cherry-pick onto target branch + if: steps.check.outputs.exists == 'false' || steps.check.outputs.exists == 'branch_only' + 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 }} + 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. + # 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 --no-merges --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.check.outputs.exists == 'branch_only') && + 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.check.outputs.exists == 'branch_only') && + 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 (backport PR already exists) + if: steps.check.outputs.exists == 'true' + env: + BRANCH: ${{ steps.check.outputs.branch }} + run: | + echo "Backport branch ${BRANCH} already exists with a PR — skipping." 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.