diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 37e17ddd2d..974a16a34d 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,10 +1,15 @@ -NOTE: this repository uses a "Merge Forward" strategy +# Merge Forward -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 +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 (applies when +targeting `ubuntu-jammy` or `ubuntu-noble`; `ubuntu-resolute` is the end of the chain): + +```text +ubuntu-jammy → ubuntu-noble → ubuntu-resolute +``` + +See [CONTRIBUTING.md](../CONTRIBUTING.md) for details. ### AI Review Feedback diff --git a/.github/workflows/merge-forward.yml b/.github/workflows/merge-forward.yml new file mode 100644 index 0000000000..d111c53468 --- /dev/null +++ b/.github/workflows/merge-forward.yml @@ -0,0 +1,171 @@ +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" + + # 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 + + - 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" "$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" + else + echo "conflict=false" >> "$GITHUB_OUTPUT" + fi + exit 0 + 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 + 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 + if: > + steps.target.outputs.target != '' && + (steps.check.outputs.exists == 'false' || steps.check.outputs.exists == 'branch_only') && + steps.merge.outputs.noop != '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 }} + 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" + + 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 + + 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_flag + + 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 4a75194451..c4e8d4ceee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,14 +1,47 @@ # 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` + +## Merge-Forward Strategy + +This repository uses a **merge-forward** strategy to keep stemcell branches in sync. + +**Branch order (oldest → newest):** + +```text +ubuntu-jammy → ubuntu-noble → ubuntu-resolute +``` + +### How it works + +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. + +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 + +- **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 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. + +### 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.