diff --git a/docs/developer/shepherding-releases.md b/docs/developer/shepherding-releases.md index 233c93bae3b..e37f398945a 100644 --- a/docs/developer/shepherding-releases.md +++ b/docs/developer/shepherding-releases.md @@ -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` diff --git a/tools/release/forwardport-release-to-main/main.go b/tools/release/forwardport-release-to-main/main.go index a567aeee199..85f0cda7032 100644 --- a/tools/release/forwardport-release-to-main/main.go +++ b/tools/release/forwardport-release-to-main/main.go @@ -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 @@ -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") @@ -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 } @@ -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. @@ -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 @@ -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) @@ -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 } diff --git a/tools/release/internal/git/git.go b/tools/release/internal/git/git.go index 8af08999c1e..56c1ac5efba 100644 --- a/tools/release/internal/git/git.go +++ b/tools/release/internal/git/git.go @@ -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 diff --git a/tools/release/internal/github/client.go b/tools/release/internal/github/client.go index 1366cb531b1..36e21846879 100644 --- a/tools/release/internal/github/client.go +++ b/tools/release/internal/github/client.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "strings" + "time" "github.com/google/go-github/v57/github" "golang.org/x/oauth2" @@ -48,6 +49,7 @@ type CreatePRParams struct { Head string Base string Body string + Draft bool } // FindCommitParams holds parameters for FindCommitWithPattern and CommitExistsWithPattern. @@ -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) @@ -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{