-
Notifications
You must be signed in to change notification settings - Fork 118
feat: Add label-triggered backport workflow #684
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ZPascal
wants to merge
4
commits into
cloudfoundry:ubuntu-jammy
Choose a base branch
from
ZPascal:feat/backport-automation
base: ubuntu-jammy
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+239
−12
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
c687b43
feat: Add label-triggered backport workflow
ZPascal ea241df
docs: Update CONTRIBUTING and PR template for label-triggered backports
ZPascal f189a86
*fix: Adjust the GH workflow permissions
ZPascal 5d1d41f
* fix: The branch_only recovery path was dead code: cherry-pick step …
ZPascal File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
|
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." | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.