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
17 changes: 11 additions & 6 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -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-<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
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

Expand Down
171 changes: 171 additions & 0 deletions .github/workflows/merge-forward.yml
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Comment on lines +104 to +107

@ZPascal ZPascal Aug 13, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disagreeing with this one. This is a merge-forward workflow, not a cherry-pick workflow — the intent is to keep the target branch fully current with the source, so using the live origin/$SOURCE tip is correct semantics. Pinning to the event's merge SHA would mean forwarding only that one PR's commits rather than everything on the source branch, which would diverge as branches accumulate. The duplicate/overlapping PR scenario doesn't materialize -> Each triggering PR produces a branch named merge-forward/$SOURCE-to-$TARGET-pr$PR_NUMBER, so two rapid merges produce two independent forward PRs with non-overlapping branch names.

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

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

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