Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -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-<short_name-N>`
2. After this PR has been merged create a PR to merge `ubuntu-<short_name-N>` into `ubuntu-<short_name-N+1>`
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

Expand Down
197 changes: 197 additions & 0 deletions .github/workflows/backport.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
name: Backport

on:
pull_request:
types: [closed]
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# 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"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# 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."
39 changes: 33 additions & 6 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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-<short_name>`

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 <target>] <your title>`.

### 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.
Loading