From c687b439d6f50827017f0bb690970d1093bc3be7 Mon Sep 17 00:00:00 2001 From: Pascal Zimmermann Date: Fri, 3 Jul 2026 11:37:18 +0200 Subject: [PATCH 1/8] 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/8] 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/8] *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/8] * 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 From 58a2e050e9eba376827b7d23cda02af8335f7950 Mon Sep 17 00:00:00 2001 From: I539231 Date: Wed, 12 Aug 2026 08:25:11 +0200 Subject: [PATCH 5/8] feat: Replace cherry-pick backport with merge-forward workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces backport.yml (label-triggered cherry-pick) with merge-forward.yml (automatic branch merge-forward on every merged PR). When a PR merges into ubuntu-jammy, the workflow opens a PR merging ubuntu-jammy→ubuntu-noble. When one merges into ubuntu-noble, it opens ubuntu-noble→ubuntu-resolute. ubuntu-resolute has no forward target. Clean merge → ready-for-review PR. Conflict → draft PR with instructions to resolve manually. Idempotency and branch_only recovery carry over from the original implementation. Updates CONTRIBUTING.md and the PR template to document the merge-forward strategy instead of backport labels. --- .github/pull_request_template.md | 13 +- .github/workflows/backport.yml | 197 ---------------------------- .github/workflows/merge-forward.yml | 172 ++++++++++++++++++++++++ CONTRIBUTING.md | 40 +++--- 4 files changed, 198 insertions(+), 224 deletions(-) delete mode 100644 .github/workflows/backport.yml create mode 100644 .github/workflows/merge-forward.yml diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index cd5a5bf9b7..ba492e3841 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,11 +1,12 @@ -# Backporting +# Merge Forward -To backport this change to other stemcell branches, add one or more labels -before merging and the workflow will open cherry-pick PRs automatically: +This repository uses a **merge-forward** strategy. Open your PR against the +**oldest applicable branch** and merge it — the workflow will automatically +open a PR merging it forward to the next branch in the chain: -- `backport/ubuntu-jammy` -- `backport/ubuntu-noble` -- `backport/ubuntu-resolute` +``` +ubuntu-jammy → ubuntu-noble → ubuntu-resolute +``` See [CONTRIBUTING.md](../CONTRIBUTING.md) for details. diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml deleted file mode 100644 index 35640042c0..0000000000 --- a/.github/workflows/backport.yml +++ /dev/null @@ -1,197 +0,0 @@ -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/.github/workflows/merge-forward.yml b/.github/workflows/merge-forward.yml new file mode 100644 index 0000000000..694aea822e --- /dev/null +++ b/.github/workflows/merge-forward.yml @@ -0,0 +1,172 @@ +name: Merge Forward + +on: + pull_request: + types: [closed] + branches: + - ubuntu-jammy + - ubuntu-noble + +# Serialize runs per source branch to prevent push races on re-runs +concurrency: + group: merge-forward-${{ github.base_ref }}-${{ github.event.pull_request.number }} + cancel-in-progress: false + +# Default: no permissions; write access is granted only to the merge-forward job +permissions: {} + +jobs: + merge-forward: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + 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: Determine forward target + id: target + env: + BASE_REF: ${{ github.base_ref }} + run: | + case "$BASE_REF" in + ubuntu-jammy) echo "target=ubuntu-noble" >> "$GITHUB_OUTPUT" ;; + ubuntu-noble) echo "target=ubuntu-resolute" >> "$GITHUB_OUTPUT" ;; + *) + echo "No forward target for $BASE_REF — skipping." + echo "target=" >> "$GITHUB_OUTPUT" + ;; + esac + + - name: Check if merge-forward branch or PR already exists + if: steps.target.outputs.target != '' + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + SOURCE: ${{ github.base_ref }} + TARGET: ${{ steps.target.outputs.target }} + run: | + branch="merge-forward/${SOURCE}-to-${TARGET}-pr${PR_NUMBER}" + echo "branch=$branch" >> "$GITHUB_OUTPUT" + + if git ls-remote --exit-code origin "refs/heads/$branch" > /dev/null 2>&1; then + pr_state=$(gh pr list --head "$branch" --state all --json state --jq '.[0].state // "NONE"') + if [ "$pr_state" = "NONE" ]; then + echo "exists=branch_only" >> "$GITHUB_OUTPUT" + else + echo "exists=true" >> "$GITHUB_OUTPUT" + fi + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Merge source into target branch + if: > + steps.target.outputs.target != '' && + (steps.check.outputs.exists == 'false' || steps.check.outputs.exists == 'branch_only') + id: merge + env: + SOURCE: ${{ github.base_ref }} + TARGET: ${{ steps.target.outputs.target }} + BRANCH: ${{ steps.check.outputs.branch }} + EXISTS: ${{ steps.check.outputs.exists }} + run: | + # Recovery path: branch exists but PR creation failed on a previous run. + # The merge result is already on the branch — skip straight to opening the PR. + 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 '^merge-forward: conflict'; then + echo "conflict=true" >> "$GITHUB_OUTPUT" + else + echo "conflict=false" >> "$GITHUB_OUTPUT" + fi + exit 0 + fi + + git fetch origin "$SOURCE" "$TARGET" + git checkout -b "$BRANCH" "origin/$TARGET" + + if git merge --no-edit "origin/$SOURCE"; then + echo "conflict=false" >> "$GITHUB_OUTPUT" + else + # Record the conflict state and push an incomplete merge branch so the + # draft PR gives reviewers a base to resolve from. + git merge --abort + # Create a placeholder commit to mark the conflict — the reviewer will + # need to check out the branch and perform the merge manually. + git commit --allow-empty -m "merge-forward: conflict merging $SOURCE into $TARGET — resolve manually" + echo "conflict=true" >> "$GITHUB_OUTPUT" + fi + + git push origin "$BRANCH" + + - name: Open merge-forward PR (clean) + if: > + steps.target.outputs.target != '' && + (steps.check.outputs.exists == 'false' || steps.check.outputs.exists == 'branch_only') && + steps.merge.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 }} + SOURCE: ${{ github.base_ref }} + TARGET: ${{ steps.target.outputs.target }} + BRANCH: ${{ steps.check.outputs.branch }} + run: | + body_file=$(mktemp) + printf 'Automated merge-forward of `%s` into `%s`, triggered by #%s.\n\nSource PR: %s\n' \ + "$SOURCE" "$TARGET" "$PR_NUMBER" "$PR_URL" > "$body_file" + + gh pr create \ + --title "[Merge Forward $SOURCE→$TARGET] $PR_TITLE" \ + --body-file "$body_file" \ + --base "$TARGET" \ + --head "$BRANCH" + + rm -f "$body_file" + + - name: Open merge-forward PR (conflict — draft) + if: > + steps.target.outputs.target != '' && + (steps.check.outputs.exists == 'false' || steps.check.outputs.exists == 'branch_only') && + steps.merge.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 }} + SOURCE: ${{ github.base_ref }} + TARGET: ${{ steps.target.outputs.target }} + BRANCH: ${{ steps.check.outputs.branch }} + run: | + body_file=$(mktemp) + printf 'Automated merge-forward of `%s` into `%s`, triggered by #%s.\n\nSource PR: %s\n\n> [!WARNING]\n> **This merge-forward has conflicts.** The merge of `%s` into `%s` could not be applied cleanly.\n> Check out branch `%s`, perform the merge manually, resolve the conflicts, and mark this PR as ready for review.\n' \ + "$SOURCE" "$TARGET" "$PR_NUMBER" "$PR_URL" "$SOURCE" "$TARGET" "$BRANCH" > "$body_file" + + gh pr create \ + --title "[Merge Forward $SOURCE→$TARGET] $PR_TITLE" \ + --body-file "$body_file" \ + --base "$TARGET" \ + --head "$BRANCH" \ + --draft + + rm -f "$body_file" + + - name: Skip (merge-forward PR already exists) + if: steps.target.outputs.target != '' && steps.check.outputs.exists == 'true' + env: + BRANCH: ${{ steps.check.outputs.branch }} + run: | + echo "Merge-forward branch ${BRANCH} already exists with a PR — skipping." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ae9f7c85d2..21b42fa7ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,34 +8,32 @@ As of `2026-06-09` the following stemcell lines / branches are supported: - Ubuntu Noble / `ubuntu-noble` - Ubuntu Resolute / `ubuntu-resolute` -## Backporting Changes Across Branches +## Merge-Forward Strategy -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. +This repository uses a **merge-forward** strategy to keep stemcell branches in sync. -### How to backport +**Branch order (oldest → newest):** -1. Open your PR against the oldest applicable branch (e.g. `ubuntu-jammy`). -2. Add one or more backport labels before merging: +``` +ubuntu-jammy → ubuntu-noble → ubuntu-resolute +``` - | Label | Target branch | - |---|---| - | `backport/ubuntu-jammy` | `ubuntu-jammy` | - | `backport/ubuntu-noble` | `ubuntu-noble` | - | `backport/ubuntu-resolute` | `ubuntu-resolute` | +### How it works -3. Merge the PR. The backport workflow opens a new PR for each labeled target - automatically, titled `[Backport ] `. +1. Open your PR against the **oldest applicable branch** (e.g. `ubuntu-jammy` if the change applies to all lines). +2. Merge it. +3. The merge-forward workflow automatically opens a PR merging that branch into the next one in the chain. +4. Merge the forwarded PR, which then triggers another forward merge to the next branch, and so on. -### Clean vs. conflict backports +No labels are needed — every merged PR triggers an automatic forward merge. -- **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. +### Clean vs. conflict merge-forwards + +- **Clean merge** — the merge-forward PR is opened ready for review. +- **Conflict** — the merge-forward PR is opened as a draft. Check out the branch, + perform the merge manually, 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. +The workflow is idempotent: if the merge-forward branch already exists (e.g. after a +failed run), re-running the workflow skips the merge step and only retries opening the PR. From 9e02fc385306f489a544a04a4dbe9ab04801249c Mon Sep 17 00:00:00 2001 From: I539231 Date: Wed, 12 Aug 2026 08:39:46 +0200 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20Address=20bot=20review=20=E2=80=94?= =?UTF-8?q?=20no-op=20check,=20MD040,=20scope=20docs,=20qualify=20branch?= =?UTF-8?q?=5Fonly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add git merge-base --is-ancestor no-op check before merge and in the branch_only recovery path: skip PR creation when source is already fully contained in target * Fix MD040: add 'text' language to fenced code blocks in CONTRIBUTING.md and pull_request_template.md * Scope automatic-forwarding language to ubuntu-jammy and ubuntu-noble (ubuntu-resolute is the chain end — no forward target) * Qualify the re-run idempotency note: branch_only triggers a PR-open retry only when no PR exists; any existing PR (open or closed) skips --- .github/pull_request_template.md | 5 +++-- .github/workflows/merge-forward.yml | 18 +++++++++++++++++- CONTRIBUTING.md | 10 ++++++---- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ba492e3841..974a16a34d 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,9 +2,10 @@ This repository uses a **merge-forward** strategy. Open your PR against the **oldest applicable branch** and merge it — the workflow will automatically -open a PR merging it forward to the next branch in the chain: +open a PR merging it forward to the next branch in the chain (applies when +targeting `ubuntu-jammy` or `ubuntu-noble`; `ubuntu-resolute` is the end of the chain): -``` +```text ubuntu-jammy → ubuntu-noble → ubuntu-resolute ``` diff --git a/.github/workflows/merge-forward.yml b/.github/workflows/merge-forward.yml index 694aea822e..b7379f6ee9 100644 --- a/.github/workflows/merge-forward.yml +++ b/.github/workflows/merge-forward.yml @@ -84,7 +84,13 @@ jobs: # Recovery path: branch exists but PR creation failed on a previous run. # The merge result is already on the branch — skip straight to opening the PR. if [ "$EXISTS" = "branch_only" ]; then - git fetch origin "$BRANCH" + git fetch origin "$BRANCH" "$SOURCE" "$TARGET" + # No-op check: if source is already an ancestor of target the branch + # has nothing new to forward — skip PR creation entirely. + if git merge-base --is-ancestor "origin/$SOURCE" "origin/$TARGET"; then + echo "noop=true" >> "$GITHUB_OUTPUT" + exit 0 + fi last_msg=$(git log -1 --pretty=%B "origin/$BRANCH") if printf '%s' "$last_msg" | grep -q '^merge-forward: conflict'; then echo "conflict=true" >> "$GITHUB_OUTPUT" @@ -95,6 +101,14 @@ jobs: fi git fetch origin "$SOURCE" "$TARGET" + + # No-op check: source already fully contained in target — nothing to forward. + if git merge-base --is-ancestor "origin/$SOURCE" "origin/$TARGET"; then + echo "No new commits from $SOURCE not already in $TARGET — skipping." + echo "noop=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + git checkout -b "$BRANCH" "origin/$TARGET" if git merge --no-edit "origin/$SOURCE"; then @@ -115,6 +129,7 @@ jobs: if: > steps.target.outputs.target != '' && (steps.check.outputs.exists == 'false' || steps.check.outputs.exists == 'branch_only') && + steps.merge.outputs.noop != 'true' && steps.merge.outputs.conflict == 'false' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -141,6 +156,7 @@ jobs: if: > steps.target.outputs.target != '' && (steps.check.outputs.exists == 'false' || steps.check.outputs.exists == 'branch_only') && + steps.merge.outputs.noop != 'true' && steps.merge.outputs.conflict == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 21b42fa7ab..bb2f2835be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ This repository uses a **merge-forward** strategy to keep stemcell branches in s **Branch order (oldest → newest):** -``` +```text ubuntu-jammy → ubuntu-noble → ubuntu-resolute ``` @@ -25,7 +25,7 @@ ubuntu-jammy → ubuntu-noble → ubuntu-resolute 3. The merge-forward workflow automatically opens a PR merging that branch into the next one in the chain. 4. Merge the forwarded PR, which then triggers another forward merge to the next branch, and so on. -No labels are needed — every merged PR triggers an automatic forward merge. +No labels are needed — every PR merged into `ubuntu-jammy` or `ubuntu-noble` triggers an automatic forward merge to the next branch. ### Clean vs. conflict merge-forwards @@ -35,5 +35,7 @@ No labels are needed — every merged PR triggers an automatic forward merge. ### Re-runs -The workflow is idempotent: if the merge-forward branch already exists (e.g. after a -failed run), re-running the workflow skips the merge step and only retries opening the PR. +The workflow is idempotent: if the merge-forward branch already exists but has no +associated PR (i.e. the branch was pushed but PR creation failed on a previous run), +re-running retries opening the PR without re-doing the merge. If a PR already exists +(open or closed), the run skips silently. From e9d1fbeb638b2048c89592db195f9a317de399e2 Mon Sep 17 00:00:00 2001 From: I539231 Date: Thu, 13 Aug 2026 07:15:48 +0200 Subject: [PATCH 7/8] feat: Optimize the workflow --- .github/workflows/merge-forward.yml | 57 ++++++++++------------------- CONTRIBUTING.md | 7 ++++ 2 files changed, 27 insertions(+), 37 deletions(-) diff --git a/.github/workflows/merge-forward.yml b/.github/workflows/merge-forward.yml index b7379f6ee9..d111c53468 100644 --- a/.github/workflows/merge-forward.yml +++ b/.github/workflows/merge-forward.yml @@ -59,13 +59,14 @@ jobs: branch="merge-forward/${SOURCE}-to-${TARGET}-pr${PR_NUMBER}" echo "branch=$branch" >> "$GITHUB_OUTPUT" - if git ls-remote --exit-code origin "refs/heads/$branch" > /dev/null 2>&1; then - pr_state=$(gh pr list --head "$branch" --state all --json state --jq '.[0].state // "NONE"') - if [ "$pr_state" = "NONE" ]; then - echo "exists=branch_only" >> "$GITHUB_OUTPUT" - else - echo "exists=true" >> "$GITHUB_OUTPUT" - fi + # Query PR history first, regardless of whether the branch still exists. + # This prevents re-opening a PR whose branch was deleted after it was closed. + pr_state=$(gh pr list --head "$branch" --state all --json state --jq '.[0].state // "NONE"') + if [ "$pr_state" != "NONE" ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + elif git ls-remote --exit-code origin "refs/heads/$branch" > /dev/null 2>&1; then + # Branch exists but no PR was ever created (previous run failed after push). + echo "exists=branch_only" >> "$GITHUB_OUTPUT" else echo "exists=false" >> "$GITHUB_OUTPUT" fi @@ -125,58 +126,40 @@ jobs: git push origin "$BRANCH" - - name: Open merge-forward PR (clean) + - name: Open merge-forward PR if: > steps.target.outputs.target != '' && (steps.check.outputs.exists == 'false' || steps.check.outputs.exists == 'branch_only') && - steps.merge.outputs.noop != 'true' && - steps.merge.outputs.conflict == 'false' + steps.merge.outputs.noop != 'true' env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + 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 }} SOURCE: ${{ github.base_ref }} TARGET: ${{ steps.target.outputs.target }} BRANCH: ${{ steps.check.outputs.branch }} + CONFLICT: ${{ steps.merge.outputs.conflict }} run: | body_file=$(mktemp) printf 'Automated merge-forward of `%s` into `%s`, triggered by #%s.\n\nSource PR: %s\n' \ "$SOURCE" "$TARGET" "$PR_NUMBER" "$PR_URL" > "$body_file" - gh pr create \ - --title "[Merge Forward $SOURCE→$TARGET] $PR_TITLE" \ - --body-file "$body_file" \ - --base "$TARGET" \ - --head "$BRANCH" - - rm -f "$body_file" + if [ "$CONFLICT" = "true" ]; then + printf '\n> [!WARNING]\n> **This merge-forward has conflicts.** The merge of `%s` into `%s` could not be applied cleanly.\n> Check out branch `%s`, perform the merge manually, resolve the conflicts, and mark this PR as ready for review.\n' \ + "$SOURCE" "$TARGET" "$BRANCH" >> "$body_file" + fi - - name: Open merge-forward PR (conflict — draft) - if: > - steps.target.outputs.target != '' && - (steps.check.outputs.exists == 'false' || steps.check.outputs.exists == 'branch_only') && - steps.merge.outputs.noop != 'true' && - steps.merge.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 }} - SOURCE: ${{ github.base_ref }} - TARGET: ${{ steps.target.outputs.target }} - BRANCH: ${{ steps.check.outputs.branch }} - run: | - body_file=$(mktemp) - printf 'Automated merge-forward of `%s` into `%s`, triggered by #%s.\n\nSource PR: %s\n\n> [!WARNING]\n> **This merge-forward has conflicts.** The merge of `%s` into `%s` could not be applied cleanly.\n> Check out branch `%s`, perform the merge manually, resolve the conflicts, and mark this PR as ready for review.\n' \ - "$SOURCE" "$TARGET" "$PR_NUMBER" "$PR_URL" "$SOURCE" "$TARGET" "$BRANCH" > "$body_file" + draft_flag="" + [ "$CONFLICT" = "true" ] && draft_flag="--draft" + # shellcheck disable=SC2086 gh pr create \ --title "[Merge Forward $SOURCE→$TARGET] $PR_TITLE" \ --body-file "$body_file" \ --base "$TARGET" \ --head "$BRANCH" \ - --draft + $draft_flag rm -f "$body_file" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb2f2835be..d275c613ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,3 +39,10 @@ The workflow is idempotent: if the merge-forward branch already exists but has n associated PR (i.e. the branch was pushed but PR creation failed on a previous run), re-running retries opening the PR without re-doing the merge. If a PR already exists (open or closed), the run skips silently. + +### CI on merge-forward PRs + +GitHub suppresses workflow runs triggered by a `GITHUB_TOKEN`-pushed branch. This +means the auto-opened merge-forward PR will not have CI results initially. A +maintainer can start CI by pushing a trivial commit to the forward branch or by +re-running the checks manually from the Actions tab. From 42a208ef9d776a349563764a89109df88de64e3f Mon Sep 17 00:00:00 2001 From: I539231 Date: Thu, 13 Aug 2026 07:54:04 +0200 Subject: [PATCH 8/8] docs: Adjust the documentation --- CONTRIBUTING.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d275c613ae..c4e8d4ceee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,5 +44,4 @@ re-running retries opening the PR without re-doing the merge. If a PR already ex GitHub suppresses workflow runs triggered by a `GITHUB_TOKEN`-pushed branch. This means the auto-opened merge-forward PR will not have CI results initially. A -maintainer can start CI by pushing a trivial commit to the forward branch or by -re-running the checks manually from the Actions tab. +maintainer can start CI by pushing a trivial commit to the forward branch.