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..1c92326f2f --- /dev/null +++ b/.github/workflows/merge-forward.yml @@ -0,0 +1,227 @@ +name: Merge Forward + +on: + workflow_dispatch: + inputs: + source_branch: + description: > + Branch to merge forward (ubuntu-jammy → ubuntu-noble, ubuntu-noble → ubuntu-resolute). + required: true + type: choice + options: + - ubuntu-jammy + - ubuntu-noble + validated_sha: + description: > + Optional: specific commit SHA that passed Concourse validation + (aggregate-candidate-stemcells). When provided, merges from this SHA + rather than the branch tip, making the validated commit explicit in the + merge-forward PR. Leave blank to use the current branch tip. + required: false + type: string + default: '' + +# Serialize runs per source branch to prevent push races on concurrent triggers +concurrency: + group: merge-forward-${{ inputs.source_branch }} + cancel-in-progress: false + +# Default: no permissions; write access is granted only to the merge-forward job +permissions: {} + +jobs: + merge-forward: + 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: + SOURCE: ${{ inputs.source_branch }} + run: | + case "$SOURCE" in + ubuntu-jammy) echo "target=ubuntu-noble" >> "$GITHUB_OUTPUT" ;; + ubuntu-noble) echo "target=ubuntu-resolute" >> "$GITHUB_OUTPUT" ;; + *) + echo "No forward target for $SOURCE — skipping." + echo "target=" >> "$GITHUB_OUTPUT" + ;; + esac + + - name: Resolve merge ref + if: steps.target.outputs.target != '' + id: ref + env: + SOURCE: ${{ inputs.source_branch }} + VALIDATED_SHA: ${{ inputs.validated_sha }} + run: | + git fetch origin "$SOURCE" + if [ -n "$VALIDATED_SHA" ]; then + # Verify the SHA exists on the source branch before trusting it. + if ! git merge-base --is-ancestor "$VALIDATED_SHA" "origin/$SOURCE"; then + echo "::error::validated_sha $VALIDATED_SHA is not an ancestor of origin/$SOURCE" + exit 1 + fi + echo "merge_ref=$VALIDATED_SHA" >> "$GITHUB_OUTPUT" + else + echo "merge_ref=origin/$SOURCE" >> "$GITHUB_OUTPUT" + fi + + - name: Check if merge-forward branch or PR already exists + if: steps.target.outputs.target != '' + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SOURCE: ${{ inputs.source_branch }} + TARGET: ${{ steps.target.outputs.target }} + VALIDATED_SHA: ${{ inputs.validated_sha }} + run: | + # Use a short SHA suffix when a specific commit was given so re-runs + # with a different SHA produce a distinct branch. + if [ -n "$VALIDATED_SHA" ]; then + short="${VALIDATED_SHA:0:8}" + branch="merge-forward/${SOURCE}-to-${TARGET}-${short}" + else + branch="merge-forward/${SOURCE}-to-${TARGET}" + fi + echo "branch=$branch" >> "$GITHUB_OUTPUT" + + # Check for an existing open PR for this source→target pair regardless of + # which validated_sha was used. Two runs with different SHAs would otherwise + # each pass the per-branch check and open duplicate PRs. + pair_prefix="merge-forward/${SOURCE}-to-${TARGET}" + open_pair_pr=$(gh pr list --base "$TARGET" --state open --json headRefName \ + --jq "[.[] | select(.headRefName | startswith(\"${pair_prefix}\"))] | length") + if [ "${open_pair_pr:-0}" -gt 0 ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Query PR history for this exact branch, 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 open --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: ${{ inputs.source_branch }} + TARGET: ${{ steps.target.outputs.target }} + BRANCH: ${{ steps.check.outputs.branch }} + EXISTS: ${{ steps.check.outputs.exists }} + MERGE_REF: ${{ steps.ref.outputs.merge_ref }} + 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" "$TARGET" + # No-op check: if merge ref is already an ancestor of target the branch + # has nothing new to forward — skip PR creation entirely. + if git merge-base --is-ancestor "$MERGE_REF" "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 "$TARGET" + + # No-op check: merge ref already fully contained in target — nothing to forward. + if git merge-base --is-ancestor "$MERGE_REF" "origin/$TARGET"; then + echo "No new commits from $MERGE_REF not already in $TARGET — skipping." + echo "noop=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git checkout -b "$BRANCH" "origin/$TARGET" + + if git merge --no-edit "$MERGE_REF"; 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 }} + SOURCE: ${{ inputs.source_branch }} + TARGET: ${{ steps.target.outputs.target }} + BRANCH: ${{ steps.check.outputs.branch }} + CONFLICT: ${{ steps.merge.outputs.conflict }} + VALIDATED_SHA: ${{ inputs.validated_sha }} + ACTOR: ${{ github.actor }} + run: | + body_file=$(mktemp) + if [ -n "$VALIDATED_SHA" ]; then + printf 'Manual merge-forward of `%s` (commit `%s`) into `%s`, triggered by @%s.\n\nThis commit has passed Concourse validation (`aggregate-candidate-stemcells`).\n' \ + "$SOURCE" "$VALIDATED_SHA" "$TARGET" "$ACTOR" > "$body_file" + else + printf 'Manual merge-forward of `%s` into `%s`, triggered by @%s.\n' \ + "$SOURCE" "$TARGET" "$ACTOR" > "$body_file" + fi + + 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]" \ + --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..a3c85d9ade 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. A maintainer manually dispatches `.github/workflows/merge-forward.yml` with `source_branch` set to the merged branch. Optionally, set `validated_sha` to the commit that passed Concourse validation (`aggregate-candidate-stemcells`) — when provided, the workflow merges from that exact SHA rather than the branch tip. +4. Merge the forwarded PR, then repeat from step 3 for the next branch in the chain. + +No labels are needed. After each relevant merge, a maintainer dispatches the workflow to create the forward PR. + +### 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), the run skips silently. + +### CI on merge-forward PRs + +GitHub suppresses workflow runs triggered by a `GITHUB_TOKEN`-pushed branch. This +means the merge-forward PR will not have CI results initially. A +maintainer can start CI by pushing a trivial commit to the forward branch.