Skip to content
Merged
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
6 changes: 4 additions & 2 deletions docs/developer/shepherding-releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,10 @@ The forwardport PRs are what allow the changelog, manifest, and related files to
on the main branch so that subsequent releases have an appropriate starting point when looking for
changes.

If this job should happen to fail when a release-please PR gets merged into a release branch, here
are the steps you can take to do it manually:
When a release-please PR is merged, a workflow automatically forwardports its content by pushing a
branch, opening a draft PR (so the zizmor check runs), waiting for zizmor to pass, then pushing to
`main` and deleting the temp branch. If that workflow fails, use the steps below to forwardport
manually.

1. `git checkout` and `git pull` both the release branch and the main branch
2. Run `git checkout main`
Expand Down
88 changes: 79 additions & 9 deletions tools/release/forwardport-release-to-main/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@ import (
"fmt"
"log"
"strings"
"time"

"github.com/grafana/alloy/tools/release/internal/git"
gh "github.com/grafana/alloy/tools/release/internal/github"
)

const (
zizmorCheckName = "zizmor GitHub Actions static analysis" // Obtained from the ruleset on GH
zizmorTimeout = 5 * time.Minute
)

func main() {
var (
prNumber int
Expand Down Expand Up @@ -50,9 +56,13 @@ func main() {
// Extract version from release branch (release/v1.15 -> v1.15)
version := strings.TrimPrefix(releaseBranch, "release/")

// Temp branch for the forwardport commit (so we can open a draft PR for zizmor)
forwardportBranch := "forwardport/" + version

fmt.Printf("🔀 Merging release branch to main after release-please PR #%d\n", prNumber)
fmt.Printf(" Release branch: %s\n", releaseBranch)
fmt.Printf(" Version: %s\n", version)
fmt.Printf(" Forwardport branch: %s\n", forwardportBranch)

// Check if the release branch is already fully merged into main
alreadyMerged, err := client.IsBranchMergedInto(ctx, releaseBranch, "main")
Expand All @@ -66,7 +76,7 @@ func main() {

if dryRun {
fmt.Println("\n🏃 DRY RUN - No changes made")
fmt.Printf("Would merge: %s → main\n", releaseBranch)
fmt.Printf("Would merge: %s → main (via draft PR on %s, wait for zizmor, then push main)\n", releaseBranch, forwardportBranch)
return
}

Expand All @@ -81,11 +91,15 @@ func main() {
log.Fatalf("Failed to configure git: %v", err)
}

// Checkout main (assumes branches are already fetched)
// Checkout main and create forwardport branch from it (so we build the commit on the side branch)
fmt.Println("🔀 Checking out main...")
if err := git.Checkout("main"); err != nil {
log.Fatalf("Failed to checkout main: %v", err)
}
fmt.Printf("📌 Creating branch %s from main...\n", forwardportBranch)
if err := git.CreateBranchFrom(forwardportBranch, "main"); err != nil {
log.Fatalf("Failed to create branch %s: %v", forwardportBranch, err)
}

// Get the merge commit SHA from the release-please PR - this contains the
// version bump and changelog updates we want to sync.
Expand All @@ -95,7 +109,7 @@ func main() {
}
fmt.Printf(" Release-please merge commit: %s\n", mergeCommitSHA[:7])

// Merge the release branch into main using "ours" strategy.
// Merge the release branch using "ours" strategy (on the forwardport branch).
// This creates a merge commit that records the release branch history (including tags)
// but keeps main's content unchanged.
commitMessage := fmt.Sprintf(`chore: Forwardport %s to main
Expand All @@ -115,13 +129,12 @@ This commit serves two purposes:
originalPR.GetTitle(),
)

fmt.Printf("🔀 Merging %s into main (ours strategy)...\n", releaseBranch)
fmt.Printf("🔀 Merging %s into %s (ours strategy)...\n", releaseBranch, forwardportBranch)
if err := git.MergeOurs(releaseBranch, commitMessage); err != nil {
log.Fatalf("Failed to merge %s into main: %v", releaseBranch, err)
log.Fatalf("Failed to merge %s: %v", releaseBranch, err)
}

// Cherry-pick the release-please changes and amend into the merge commit.
// This brings in the version bumps and changelog updates.
fmt.Printf("📄 Cherry-picking release-please changes from %s...\n", mergeCommitSHA[:7])
if err := git.CherryPick(mergeCommitSHA, false); err != nil {
log.Fatalf("Failed to cherry-pick release-please commit: %v", err)
Expand All @@ -131,11 +144,68 @@ This commit serves two purposes:
log.Fatalf("Failed to amend merge commit: %v", err)
}

// Push the result
fmt.Println("📤 Pushing to origin...")
// Push the forwardport branch (not main yet)
fmt.Printf("📤 Pushing branch %s...\n", forwardportBranch)
if err := git.Push(forwardportBranch); err != nil {
log.Fatalf("Failed to push %s: %v", forwardportBranch, err)
}
defer cleanupBranch(ctx, client, forwardportBranch)

// Open a draft PR so zizmor runs on the commit; we wait for it before pushing to main.
draftPR, err := client.CreatePR(ctx, gh.CreatePRParams{
Title: fmt.Sprintf("chore: Forwardport %s to main", releaseBranch),
Head: forwardportBranch,
Base: "main",
Body: fmt.Sprintf("Automated forwardport. Triggered by release-please PR #%d.\n\nDo not merge manually; the workflow will push to main after zizmor passes.", originalPR.GetNumber()),
Draft: true,
})
if err != nil {
cleanupBranch(ctx, client, forwardportBranch)
log.Fatalf("Failed to create draft PR: %v", err)
}
fmt.Printf("📋 Created draft PR #%d: %s\n", draftPR.GetNumber(), draftPR.GetHTMLURL())

if err := waitForZizmor(ctx, client, forwardportBranch); err != nil {
cleanupBranch(ctx, client, forwardportBranch)
log.Fatalf("Zizmor did not pass in time or failed: %v", err)
}

// Fast-forward main to the forwardport commit and push
fmt.Println("🔀 Checking out main and merging forwardport branch...")
if err := git.Checkout("main"); err != nil {
cleanupBranch(ctx, client, forwardportBranch)
log.Fatalf("Failed to checkout main: %v", err)
}
if err := git.MergeFFOnly(forwardportBranch); err != nil {
cleanupBranch(ctx, client, forwardportBranch)
log.Fatalf("Failed to merge %s into main: %v", forwardportBranch, err)
}
fmt.Println("📤 Pushing main...")
if err := git.Push("main"); err != nil {
cleanupBranch(ctx, client, forwardportBranch)
log.Fatalf("Failed to push main: %v", err)
}

fmt.Printf("✅ Merged %s into main (ours strategy, with release-please changes)\n", releaseBranch)
fmt.Printf("✅ Merged %s into main (forwardport PR #%d closed)\n", releaseBranch, draftPR.GetNumber())
}

// cleanupBranch deletes the temporary forwardport branch from the remote.
func cleanupBranch(ctx context.Context, client *gh.Client, branch string) {
if err := client.DeleteBranch(ctx, branch); err != nil {
log.Printf("⚠️ Failed to delete branch %s: %v", branch, err)
} else {
fmt.Printf("🗑️ Deleted branch %s\n", branch)
}
}

// waitForZizmor polls until the zizmor check passes on ref or the timeout is reached.
func waitForZizmor(ctx context.Context, client *gh.Client, ref string) error {
waitCtx, cancel := context.WithTimeout(ctx, zizmorTimeout)
defer cancel()
fmt.Printf("⏳ Waiting for %s check (timeout %s)...\n", zizmorCheckName, zizmorTimeout)
if err := client.WaitForCheckRun(waitCtx, ref, zizmorCheckName); err != nil {
return err
}
fmt.Printf("✅ %s check passed\n", zizmorCheckName)
return nil
}
11 changes: 11 additions & 0 deletions tools/release/internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,17 @@ func Push(branch string) error {
return nil
}

// MergeFFOnly merges the given branch into the current branch using fast-forward only.
func MergeFFOnly(branch string) error {
if err := validateBranchName(branch); err != nil {
return err
}
if err := run("git", "merge", "--ff-only", branch); err != nil {
return fmt.Errorf("merging branch %s (ff-only): %w", branch, err)
}
return nil
}

// CoAuthor represents a co-author extracted from a commit message.
type CoAuthor struct {
Name string
Expand Down
57 changes: 57 additions & 0 deletions tools/release/internal/github/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"net/http"
"os"
"strings"
"time"

"github.com/google/go-github/v57/github"
"golang.org/x/oauth2"
Expand Down Expand Up @@ -48,6 +49,7 @@ type CreatePRParams struct {
Head string
Base string
Body string
Draft bool
}

// FindCommitParams holds parameters for FindCommitWithPattern and CommitExistsWithPattern.
Expand Down Expand Up @@ -261,6 +263,7 @@ func (c *Client) CreatePR(ctx context.Context, p CreatePRParams) (*github.PullRe
Head: github.String(p.Head),
Base: github.String(p.Base),
Body: github.String(p.Body),
Draft: github.Bool(p.Draft),
}

pr, _, err := c.api.PullRequests.Create(ctx, c.owner, c.repo, newPR)
Expand Down Expand Up @@ -366,6 +369,60 @@ func (c *Client) UpdateReleaseBody(ctx context.Context, releaseID int64, body st
return nil
}

// WaitForCheckRun polls until the named check run has completed successfully on the given ref,
// or until the context is done (e.g. timeout). Ref can be a branch name or commit SHA.
func (c *Client) WaitForCheckRun(ctx context.Context, ref, checkName string) error {
opts := &github.ListCheckRunsOptions{
Filter: github.String("latest"),
ListOptions: github.ListOptions{
PerPage: 100,
},
}
for {
select {
case <-ctx.Done():
return fmt.Errorf("waiting for check %q: %w", checkName, ctx.Err())
default:
}
result, _, err := c.api.Checks.ListCheckRunsForRef(ctx, c.owner, c.repo, ref, opts)
if err != nil {
return fmt.Errorf("listing check runs for ref %s: %w", ref, err)
}
var found *github.CheckRun
for _, run := range result.CheckRuns {
if run.GetName() == checkName {
found = run
break
}
}
if found == nil {
// Check not yet reported; wait and retry
time.Sleep(20 * time.Second)
continue
}
status := found.GetStatus()
if status != "completed" {
time.Sleep(20 * time.Second)
continue
}
conclusion := found.GetConclusion()
if conclusion != "success" {
return fmt.Errorf("check %q completed with conclusion %q (expected success)", checkName, conclusion)
}
return nil
}
}

// DeleteBranch deletes the branch ref on the remote.
func (c *Client) DeleteBranch(ctx context.Context, branch string) error {
ref := "refs/heads/" + branch
_, err := c.api.Git.DeleteRef(ctx, c.owner, c.repo, ref)
if err != nil {
return fmt.Errorf("deleting branch %s: %w", branch, err)
}
return nil
}

// CreateIssueComment adds a comment to an issue or pull request.
func (c *Client) CreateIssueComment(ctx context.Context, issueNumber int, body string) error {
comment := &github.IssueComment{
Expand Down
Loading