From fc366aa5743daa35f3581eb0a87c0032bf3be45d Mon Sep 17 00:00:00 2001 From: Dan Barr <6922515+danbarr@users.noreply.github.com> Date: Mon, 2 Feb 2026 19:15:00 -0500 Subject: [PATCH] Add git trailer to preserve workflow triggering actor Read the GITHUB_ACTOR environment variable and add a Release-Triggered-By git trailer to commit messages. This allows downstream workflows to identify the original release initiator when using PATs for multi-stage releases. The trailer can be extracted with: git log -1 --format='%(trailers:key=Release-Triggered-By,valueonly)' Closes #20 Co-Authored-By: Claude Opus 4.5 --- .gitignore | 2 ++ internal/github/client.go | 17 +++++----- internal/github/client_test.go | 58 ++++++++++++++++++++++++++++++++++ internal/github/pr.go | 8 +++-- main.go | 17 ++++++---- main_test.go | 58 +++++++++++++++++++++++++++------- 6 files changed, 131 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index be9d7e1..7b77d5c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ releaseo # GoReleaser dist/ + +.task/checksum/build diff --git a/internal/github/client.go b/internal/github/client.go index dab57a4..907e08e 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -83,15 +83,16 @@ func NewClient(ctx context.Context, token string, opts ...ClientOption) (*Client } // PRRequest contains the parameters for creating a pull request. -// All fields except Body are required. +// All fields except Body and TriggeredBy are required. type PRRequest struct { - Owner string // GitHub repository owner (required) - Repo string // GitHub repository name (required) - BaseBranch string // Base branch for the PR (required, e.g., "main") - HeadBranch string // Feature branch to create (required) - Title string // PR title (required) - Body string // PR body/description - Files []string // Files to commit (required, must not be empty) + Owner string // GitHub repository owner (required) + Repo string // GitHub repository name (required) + BaseBranch string // Base branch for the PR (required, e.g., "main") + HeadBranch string // Feature branch to create (required) + Title string // PR title (required) + Body string // PR body/description + Files []string // Files to commit (required, must not be empty) + TriggeredBy string // GitHub actor who triggered the release (optional, added as git trailer) } // Validate checks that all required fields are set. diff --git a/internal/github/client_test.go b/internal/github/client_test.go index a837389..9e81413 100644 --- a/internal/github/client_test.go +++ b/internal/github/client_test.go @@ -117,6 +117,16 @@ func TestPRRequest_Validate(t *testing.T) { modify: func(r *PRRequest) { r.Body = "" }, wantErr: "", }, + { + name: "triggered by is optional", + modify: func(r *PRRequest) { r.TriggeredBy = "" }, + wantErr: "", + }, + { + name: "triggered by with value", + modify: func(r *PRRequest) { r.TriggeredBy = "someuser" }, + wantErr: "", + }, } for _, tt := range tests { @@ -189,3 +199,51 @@ func TestClient_ImplementsPRCreator(t *testing.T) { // Runtime assertion that Client implements PRCreator interface. var _ PRCreator = client } + +// TestCommitMessageFormat tests the commit message format with and without git trailer. +// This tests the format logic used in commitFile(). +func TestCommitMessageFormat(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fileName string + triggeredBy string + wantMessage string + }{ + { + name: "without triggered by", + fileName: "VERSION", + triggeredBy: "", + wantMessage: "Update VERSION for release", + }, + { + name: "with triggered by", + fileName: "VERSION", + triggeredBy: "testuser", + wantMessage: "Update VERSION for release\n\nRelease-Triggered-By: testuser", + }, + { + name: "with triggered by on Chart.yaml", + fileName: "Chart.yaml", + triggeredBy: "releasebot", + wantMessage: "Update Chart.yaml for release\n\nRelease-Triggered-By: releasebot", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Replicate the message format logic from commitFile() + message := "Update " + tt.fileName + " for release" + if tt.triggeredBy != "" { + message += "\n\nRelease-Triggered-By: " + tt.triggeredBy + } + + if message != tt.wantMessage { + t.Errorf("commit message = %q, want %q", message, tt.wantMessage) + } + }) + } +} diff --git a/internal/github/pr.go b/internal/github/pr.go index abddd37..14c98ba 100644 --- a/internal/github/pr.go +++ b/internal/github/pr.go @@ -47,7 +47,7 @@ func (c *Client) CreateReleasePR(ctx context.Context, req PRRequest) (*PRResult, // Commit the files to the new branch for _, filePath := range req.Files { - if err := c.commitFile(ctx, req.Owner, req.Repo, req.HeadBranch, filePath); err != nil { + if err := c.commitFile(ctx, req.Owner, req.Repo, req.HeadBranch, filePath, req.TriggeredBy); err != nil { return nil, fmt.Errorf("committing file %s: %w", filePath, err) } } @@ -73,7 +73,8 @@ func (c *Client) CreateReleasePR(ctx context.Context, req PRRequest) (*PRResult, } // commitFile commits a single file to a branch. -func (c *Client) commitFile(ctx context.Context, owner, repo, branch, filePath string) error { +// If triggeredBy is non-empty, a git trailer is added to the commit message. +func (c *Client) commitFile(ctx context.Context, owner, repo, branch, filePath, triggeredBy string) error { // Read file content using the fileReader interface content, err := c.fileReader.ReadFile(filePath) if err != nil { @@ -87,6 +88,9 @@ func (c *Client) commitFile(ctx context.Context, owner, repo, branch, filePath s ) message := fmt.Sprintf("Update %s for release", filepath.Base(filePath)) + if triggeredBy != "" { + message += fmt.Sprintf("\n\nRelease-Triggered-By: %s", triggeredBy) + } opts := &github.RepositoryContentFileOptions{ Message: github.String(message), diff --git a/main.go b/main.go index c7e2571..6ca03dc 100644 --- a/main.go +++ b/main.go @@ -40,6 +40,7 @@ type Config struct { RepoOwner string RepoName string BaseBranch string + TriggeredBy string } // Dependencies holds the external dependencies for the release process. @@ -212,13 +213,14 @@ func createReleasePR( allFiles = append(allFiles, helmDocsFiles...) pr, err := prCreator.CreateReleasePR(ctx, github.PRRequest{ - Owner: cfg.RepoOwner, - Repo: cfg.RepoName, - BaseBranch: cfg.BaseBranch, - HeadBranch: branchName, - Title: prTitle, - Body: prBody, - Files: allFiles, + Owner: cfg.RepoOwner, + Repo: cfg.RepoName, + BaseBranch: cfg.BaseBranch, + HeadBranch: branchName, + Title: prTitle, + Body: prBody, + Files: allFiles, + TriggeredBy: cfg.TriggeredBy, }) if err != nil { return nil, fmt.Errorf("creating PR: %w", err) @@ -243,6 +245,7 @@ func parseFlags() Config { cfg.VersionFiles = parseVersionFiles(versionFilesJSON) cfg.Token = resolveToken(cfg.Token) cfg.RepoOwner, cfg.RepoName = parseRepository() + cfg.TriggeredBy = os.Getenv("GITHUB_ACTOR") validateConfig(cfg) diff --git a/main_test.go b/main_test.go index c6bdc97..719d9c3 100644 --- a/main_test.go +++ b/main_test.go @@ -54,11 +54,13 @@ func (m *mockYAMLUpdater) UpdateYAMLFile(_ files.VersionFileConfig, _, _ string) // mockPRCreator implements github.PRCreator for testing. type mockPRCreator struct { - result *github.PRResult - err error + result *github.PRResult + err error + lastRequest github.PRRequest // captures the last request for verification } -func (m *mockPRCreator) CreateReleasePR(_ context.Context, _ github.PRRequest) (*github.PRResult, error) { +func (m *mockPRCreator) CreateReleasePR(_ context.Context, req github.PRRequest) (*github.PRResult, error) { + m.lastRequest = req return m.result, m.err } @@ -383,15 +385,16 @@ func TestCreateReleasePR(t *testing.T) { t.Parallel() tests := []struct { - name string - cfg Config - prCreator *mockPRCreator - newVersion string - helmDocsFiles []string - wantErr bool - errContains string - wantPRNumber int - wantPRURL string + name string + cfg Config + prCreator *mockPRCreator + newVersion string + helmDocsFiles []string + wantErr bool + errContains string + wantPRNumber int + wantPRURL string + wantTriggeredBy string }{ { name: "success", @@ -454,6 +457,29 @@ func TestCreateReleasePR(t *testing.T) { wantErr: true, errContains: "creating PR", }, + { + name: "success with triggered by actor", + cfg: Config{ + RepoOwner: "owner", + RepoName: "repo", + BaseBranch: "main", + BumpType: "minor", + VersionFile: "VERSION", + TriggeredBy: "testuser", + }, + prCreator: &mockPRCreator{ + result: &github.PRResult{ + Number: 789, + URL: "https://github.com/owner/repo/pull/789", + }, + err: nil, + }, + newVersion: "1.1.0", + wantErr: false, + wantPRNumber: 789, + wantPRURL: "https://github.com/owner/repo/pull/789", + wantTriggeredBy: "testuser", + }, } for _, tt := range tests { @@ -484,6 +510,14 @@ func TestCreateReleasePR(t *testing.T) { if result.URL != tt.wantPRURL { t.Errorf("createReleasePR() PR URL = %q, want %q", result.URL, tt.wantPRURL) } + + // Verify TriggeredBy is passed through to the PRRequest + if tt.wantTriggeredBy != "" { + if tt.prCreator.lastRequest.TriggeredBy != tt.wantTriggeredBy { + t.Errorf("createReleasePR() TriggeredBy = %q, want %q", + tt.prCreator.lastRequest.TriggeredBy, tt.wantTriggeredBy) + } + } }) } }