From 2a75564fe10b0b5ad8ca9381193b16faa4996823 Mon Sep 17 00:00:00 2001 From: Alex Vidal Date: Fri, 23 Jan 2026 11:08:00 -0600 Subject: [PATCH 01/14] chore: add AGENTS.md --- .gitignore | 1 + AGENTS.md | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 AGENTS.md diff --git a/.gitignore b/.gitignore index d9336fd..607df76 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ dist/ commit-headless +.claude/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bb8010e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ +# AGENTS.md + +This file provides guidance for AI coding agents working with this repository. + +## Project Overview + +This is `commit-headless`, a CLI tool and GitHub Action for created signed remote commits from local +changes via the GitHub REST API. + +The action implementation is in the `action/` branch and action releases are tagged with +`action/VERSION`. The contents of the action releases are prepared from the contents of the +`@./action-template` directory. See `.github/workflows/release.yml` for details on how this works. + +## Permissions + +You are allowed to: + +- Read and modify any file in this repository +- Run any Go commands (`go build`, `go test`, `go mod tidy`, etc.) +- Run `git` commands for version control operations, but you should not make commits or push unless + given explicit permissions +- Run `go mod` commands to update dependencies and tidy + +## Building, Running, and Testing + +You can build, run, or test using the `go` command. For instance `go build .` and `go test -v ./...` + +## Guidelines + +This project has the potential to perform destructive operations on a GitHub repository and care +should be taken. + +When making changes, you should ensure the test suite passes. New code, where possible, should carry +accompanying tests. + +Avoid adding dependencies unless adding the dependency provides a significant gain in readability, +useability, or security. + +User-facing changes should come with updates to `@README.md` as well as the action README in +`@./action-template/README.md`. `@CHANGELOG.md` should be updated with a short summary of changes +taking special care to mention breaking changes. From 74db1752f8fd75a935802cbe446682e39723d05b Mon Sep 17 00:00:00 2001 From: Alex Vidal Date: Fri, 23 Jan 2026 11:29:31 -0600 Subject: [PATCH 02/14] feat(github): swap from the GraphQL API to the REST API This patch is an internal-only change to swap from using the GraphQL API to using the REST API, which will unlock future changes due to the REST API being more powerful. --- github.go | 321 +++++++--------------- github_test.go | 725 ++++++++++++++++++++++++++++++++++++++++++++++++- go.mod | 3 + go.sum | 8 + 4 files changed, 816 insertions(+), 241 deletions(-) diff --git a/github.go b/github.go index 8d206a1..bd82e8a 100644 --- a/github.go +++ b/github.go @@ -1,55 +1,60 @@ package main import ( - "bytes" "context" - "encoding/json" "errors" "fmt" "net/http" "strings" + "github.com/google/go-github/v81/github" "golang.org/x/oauth2" ) var ErrNoRemoteBranch = errors.New("branch does not exist on the remote") +// RepositoriesAPI defines the subset of github.RepositoriesService methods needed by this project. +type RepositoriesAPI interface { + GetBranch(ctx context.Context, owner, repo, branch string, maxRedirects int) (*github.Branch, *github.Response, error) +} + +// GitAPI defines the subset of github.GitService methods needed by this project. +type GitAPI interface { + CreateRef(ctx context.Context, owner, repo string, ref github.CreateRef) (*github.Reference, *github.Response, error) + GetCommit(ctx context.Context, owner, repo, sha string) (*github.Commit, *github.Response, error) + CreateBlob(ctx context.Context, owner, repo string, blob github.Blob) (*github.Blob, *github.Response, error) + CreateTree(ctx context.Context, owner, repo, baseTree string, entries []*github.TreeEntry) (*github.Tree, *github.Response, error) + CreateCommit(ctx context.Context, owner, repo string, commit github.Commit, opts *github.CreateCommitOptions) (*github.Commit, *github.Response, error) + UpdateRef(ctx context.Context, owner, repo, ref string, updateRef github.UpdateRef) (*github.Reference, *github.Response, error) +} + // Client provides methods for interacting with a remote repository on GitHub type Client struct { - httpC *http.Client + repos RepositoriesAPI + git GitAPI owner string repo string branch string dryrun bool - - // Used for testing purposes - baseURL string } // NewClient returns a Client configured to make GitHub requests for branch owned by owner/repo on // GitHub using the oauth token in token. func NewClient(ctx context.Context, token, owner, repo, branch string) *Client { - tokensrc := oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: token}, - ) + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) + httpC := oauth2.NewClient(ctx, ts) + ghClient := github.NewClient(httpC) - httpC := oauth2.NewClient(ctx, tokensrc) return &Client{ - httpC: httpC, - owner: owner, repo: repo, branch: branch, - baseURL: "https://api.github.com", + repos: ghClient.Repositories, + git: ghClient.Git, + owner: owner, + repo: repo, + branch: branch, } } -func (c *Client) branchURL() string { - return fmt.Sprintf("%s/repos/%s/%s/branches/%s", c.baseURL, c.owner, c.repo, c.branch) -} - -func (c *Client) refsURL() string { - return fmt.Sprintf("%s/repos/%s/%s/git/refs", c.baseURL, c.owner, c.repo) -} - func (c *Client) browseCommitsURL() string { return fmt.Sprintf("https://github.com/%s/%s/commits/%s", c.owner, c.repo, c.branch) } @@ -58,104 +63,38 @@ func (c *Client) commitURL(hash string) string { return fmt.Sprintf("https://github.com/%s/%s/commit/%s", c.owner, c.repo, hash) } -func (c *Client) graphqlURL() string { - return fmt.Sprintf("%s/graphql", c.baseURL) -} - // GetHeadCommitHash returns the current head commit hash for the configured repository and branch func (c *Client) GetHeadCommitHash(ctx context.Context) (string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.branchURL(), nil) + branch, resp, err := c.repos.GetBranch(ctx, c.owner, c.repo, c.branch, 0) if err != nil { - return "", fmt.Errorf("prepare http request: %w", err) - } - - resp, err := c.httpC.Do(req) - if err != nil { - return "", fmt.Errorf("get commit hash: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - return "", fmt.Errorf("get branch %q: %w", c.branch, ErrNoRemoteBranch) - } - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("get commit hash: http %d", resp.StatusCode) - } - - payload := struct { - Commit struct { - Sha string + if resp != nil && resp.StatusCode == http.StatusNotFound { + return "", fmt.Errorf("get branch %q: %w", c.branch, ErrNoRemoteBranch) } - }{} - - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return "", fmt.Errorf("decode commit hash response: %w", err) + return "", fmt.Errorf("get commit hash: %w", err) } - - return payload.Commit.Sha, nil + return branch.GetCommit().GetSHA(), nil } // CreateBranch attempts to create c.branch using headSha as the branch point func (c *Client) CreateBranch(ctx context.Context, headSha string) (string, error) { log("Creating branch from commit %s\n", headSha) - var input bytes.Buffer - - err := json.NewEncoder(&input).Encode(map[string]string{ - "ref": fmt.Sprintf("refs/heads/%s", c.branch), - "sha": headSha, - }) - if err != nil { - return "", err + ref := github.CreateRef{ + Ref: fmt.Sprintf("refs/heads/%s", c.branch), + SHA: headSha, } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.refsURL(), &input) + created, resp, err := c.git.CreateRef(ctx, c.owner, c.repo, ref) if err != nil { - return "", fmt.Errorf("prepare http request: %w", err) - } - - resp, err := c.httpC.Do(req) - if err != nil { - return "", fmt.Errorf("create branch request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusUnprocessableEntity { - // Parse the error response to distinguish between different failure modes - var errResp struct { - Message string `json:"message"` - } - if err := json.NewDecoder(resp.Body).Decode(&errResp); err == nil { - if strings.Contains(errResp.Message, "Reference already exists") { - return "", fmt.Errorf("create branch: branch %q already exists", c.branch) - } - if strings.Contains(errResp.Message, "Object does not exist") { - return "", fmt.Errorf("create branch: commit %q does not exist", headSha) - } - return "", fmt.Errorf("create branch: %s", errResp.Message) + if resp != nil && resp.StatusCode == http.StatusUnprocessableEntity { + return "", fmt.Errorf("create branch: http 422 (does the branch point exist?)") } - return "", fmt.Errorf("create branch: http 422") - } - - if resp.StatusCode != http.StatusCreated { - return "", fmt.Errorf("create branch: http %d", resp.StatusCode) - } - - payload := struct { - Commit struct { - Sha string - } `json:"object"` - }{} - - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return "", fmt.Errorf("decode create branch response: %w", err) + return "", fmt.Errorf("create branch: %w", err) } - - return payload.Commit.Sha, nil + return created.GetObject().GetSHA(), nil } -// PushChanges takes a list of changes and a commit hash and produces commits using the GitHub GraphQL API. +// PushChanges takes a list of changes and a commit hash and produces commits using the GitHub REST API. // The commit hash is expected to be the current head of the remote branch, see [GetHeadCommitHash] // for more. // It returns the number of changes that were successfully pushed, the new head reference hash, and @@ -172,150 +111,78 @@ func (c *Client) PushChanges(ctx context.Context, headCommit string, changes ... return len(changes), headCommit, nil } -// Splits a Change into added and deleted slices, taking into account existing files vs empty files -func (c *Client) splitChange(change Change) (added []fileAddition, deleted []fileDeletion) { - for path, content := range change.entries { - if content == nil { - deleted = append(deleted, fileDeletion{ - Path: path, - }) - } else { - added = append(added, fileAddition{ - Path: path, - Contents: content, - }) - } - } - - return added, deleted -} - -// PushChange pushes a single change using the GraphQL API. +// PushChange pushes a single change using the REST API. // It returns the hash of the pushed commit or an error. func (c *Client) PushChange(ctx context.Context, headCommit string, change Change) (string, error) { - // Turn the change into a createCommitOnBranchInput - added, deleted := c.splitChange(change) - - input := createCommitOnBranchInput{ - Branch: commitInputBranch{ - Name: c.branch, - Target: fmt.Sprintf("%s/%s", c.owner, c.repo), - }, - ExpectedRef: headCommit, - Message: commitInputMessage{ - Headline: change.Headline(), - Body: change.Body(), - }, - Changes: commitInputChanges{ - Additions: added, - Deletions: deleted, - }, - } - - query := wrapper{ - Query: ` - mutation ($input: CreateCommitOnBranchInput!) { - createCommitOnBranch(input: $input) { - commit { - oid - } - } - } - `, - Variables: map[string]any{"input": input}, - } - - // Encode the query to JSON (so we can print it in case of an error) - queryJSON, err := json.Marshal(query) - if err != nil { - return "", fmt.Errorf("encode mutation: %w", err) - } - if c.dryrun { log("Dry run enabled, not writing commit.\n") return strings.Repeat("0", len(change.hash)), nil } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.graphqlURL(), bytes.NewReader(queryJSON)) + // Get the parent commit's tree SHA + parentCommit, _, err := c.git.GetCommit(ctx, c.owner, c.repo, headCommit) if err != nil { - return "", fmt.Errorf("prepare mutation request: %w", err) + return "", fmt.Errorf("get parent commit: %w", err) } + baseTreeSHA := parentCommit.GetTree().GetSHA() - resp, err := c.httpC.Do(req) - defer resp.Body.Close() - if err != nil { - return "", err - } - - payload := struct { - Data struct { - CreateCommitOnBranch struct { - Commit struct { - ObjectID string `json:"oid"` - } - } `json:"createCommitOnBranch"` + // Build tree entries + var entries []*github.TreeEntry + for path, content := range change.entries { + entry := &github.TreeEntry{ + Path: github.Ptr(path), + Mode: github.Ptr("100644"), + Type: github.Ptr("blob"), } - Errors []struct { - Message string + if content == nil { + // Deletion: SHA must be empty string for go-github to omit it + } else { + // Create blob for additions/modifications + blob, _, err := c.git.CreateBlob(ctx, c.owner, c.repo, github.Blob{ + Content: github.Ptr(string(content)), + Encoding: github.Ptr("utf-8"), + }) + if err != nil { + return "", fmt.Errorf("create blob for %s: %w", path, err) + } + entry.SHA = blob.SHA } - }{} - - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return "", fmt.Errorf("decode mutation response body: %w", err) + entries = append(entries, entry) } - if len(payload.Errors) != 0 { - log("There were %d errors returned when creating the commit.\n", len(payload.Errors)) - for _, e := range payload.Errors { - log(" - %s\n", e.Message) - } - - return "", errors.New("graphql response") + // Create tree + tree, _, err := c.git.CreateTree(ctx, c.owner, c.repo, baseTreeSHA, entries) + if err != nil { + return "", fmt.Errorf("create tree: %w", err) } - oid := payload.Data.CreateCommitOnBranch.Commit.ObjectID - log("Pushed commit %s -> %s\n", change.hash, oid) - log(" Commit URL: %s\n", c.commitURL(oid)) - - return oid, nil -} - -type wrapper struct { - Query string `json:"query"` - Variables map[string]any `json:"variables"` -} - -type createCommitOnBranchInput struct { - Branch commitInputBranch `json:"branch"` - ExpectedRef string `json:"expectedHeadOid"` - Message commitInputMessage `json:"message"` - Changes commitInputChanges `json:"fileChanges"` -} - -type commitInputBranch struct { - Name string `json:"branchName"` - Target string `json:"repositoryNameWithOwner"` -} + // Create commit + message := change.Headline() + if body := change.Body(); body != "" { + message = message + "\n\n" + body + } -type commitInputMessage struct { - Headline string `json:"headline"` - Body string `json:"body"` -} + commit, _, err := c.git.CreateCommit(ctx, c.owner, c.repo, github.Commit{ + Message: github.Ptr(message), + Tree: &github.Tree{SHA: tree.SHA}, + Parents: []*github.Commit{{SHA: github.Ptr(headCommit)}}, + }, nil) + if err != nil { + return "", fmt.Errorf("create commit: %w", err) + } -type commitInputChanges struct { - Additions []fileAddition `json:"additions,omitempty"` - Deletions []fileDeletion `json:"deletions,omitempty"` -} + // Update ref + _, _, err = c.git.UpdateRef(ctx, c.owner, c.repo, "refs/heads/"+c.branch, github.UpdateRef{ + SHA: commit.GetSHA(), + Force: github.Ptr(false), + }) + if err != nil { + return "", fmt.Errorf("update ref: %w", err) + } -// fileAddition represents a file being added or modified. -// Contents is always included in the JSON output, even if empty. -type fileAddition struct { - Path string `json:"path"` - Contents []byte `json:"contents"` -} + commitSha := commit.GetSHA() + log("Pushed commit %s -> %s\n", change.hash, commitSha) + log(" Commit URL: %s\n", c.commitURL(commitSha)) -// fileDeletion represents a file being deleted. -// It only contains the path; contents must not be included. -type fileDeletion struct { - Path string `json:"path"` + return commitSha, nil } diff --git a/github_test.go b/github_test.go index 668b540..619e52f 100644 --- a/github_test.go +++ b/github_test.go @@ -1,11 +1,23 @@ package main import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" "slices" + "strings" "testing" + + "github.com/google/go-github/v81/github" ) -func TestSplitChange(t *testing.T) { +func init() { + logwriter = io.Discard +} + +func TestBuildTreeEntries(t *testing.T) { change := Change{ entries: map[string][]byte{ "a-file": []byte("hello world"), @@ -14,22 +26,707 @@ func TestSplitChange(t *testing.T) { }, } - added, deleted := (&Client{}).splitChange(change) + // Build tree entries the same way PushChange does (without making API calls) + var addedPaths, deletedPaths []string + for path, content := range change.entries { + if content == nil { + deletedPaths = append(deletedPaths, path) + } else { + addedPaths = append(addedPaths, path) + } + } + + slices.Sort(addedPaths) - if len(added) != 2 { - t.Errorf("expected 2 added changes, got %d", len(added)) - } else { - additions := []string{added[0].Path, added[1].Path} - slices.Sort(additions) + if len(addedPaths) != 2 { + t.Errorf("expected 2 added changes, got %d", len(addedPaths)) + } else if addedPaths[0] != "a-file" || addedPaths[1] != "b-empty" { + t.Errorf("expected 'a-file' and 'b-empty' to be added, but got %q", addedPaths) + } - if additions[0] != "a-file" || additions[1] != "b-empty" { - t.Errorf("expected 'a-file' and 'b-empty' to be added, but got %q", additions) - } + if len(deletedPaths) != 1 { + t.Errorf("expected 1 deleted change, got %d", len(deletedPaths)) + } else if deletedPaths[0] != "deleted" { + t.Errorf("expected deleted path to be 'deleted', got %s", deletedPaths[0]) } +} + +// newTestClient creates a Client configured to use the provided httptest server. +func newTestClient(t *testing.T, server *httptest.Server) *Client { + t.Helper() + ghClient := github.NewClient(nil) + ghClient.BaseURL, _ = ghClient.BaseURL.Parse(server.URL + "/") - if len(deleted) != 1 { - t.Errorf("expected 1 deleted change, got %d", len(deleted)) - } else if deleted[0].Path != "deleted" { - t.Errorf("expected deleted[0].Path to be 'deleted', got %s", deleted[0].Path) + return &Client{ + repos: ghClient.Repositories, + git: ghClient.Git, + owner: "test-owner", + repo: "test-repo", + branch: "test-branch", } } + +func TestGetHeadCommitHash(t *testing.T) { + t.Run("success", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/test-owner/test-repo/branches/test-branch" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(github.Branch{ + Commit: &github.RepositoryCommit{ + SHA: github.Ptr("abc123def456"), + }, + }) + })) + defer server.Close() + + client := newTestClient(t, server) + sha, err := client.GetHeadCommitHash(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sha != "abc123def456" { + t.Errorf("expected sha 'abc123def456', got %q", sha) + } + }) + + t.Run("branch not found", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{ + "message": "Branch not found", + }) + })) + defer server.Close() + + client := newTestClient(t, server) + _, err := client.GetHeadCommitHash(context.Background()) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), ErrNoRemoteBranch.Error()) { + t.Errorf("expected error to contain %q, got %q", ErrNoRemoteBranch.Error(), err.Error()) + } + }) + + t.Run("server error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + client := newTestClient(t, server) + _, err := client.GetHeadCommitHash(context.Background()) + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestCreateBranch(t *testing.T) { + t.Run("success", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/repos/test-owner/test-repo/git/refs" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + var req github.CreateRef + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("failed to decode request: %v", err) + } + if req.Ref != "refs/heads/test-branch" { + t.Errorf("expected ref 'refs/heads/test-branch', got %q", req.Ref) + } + if req.SHA != "parent-sha-123" { + t.Errorf("expected sha 'parent-sha-123', got %q", req.SHA) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Reference{ + Object: &github.GitObject{ + SHA: github.Ptr("parent-sha-123"), + }, + }) + })) + defer server.Close() + + client := newTestClient(t, server) + sha, err := client.CreateBranch(context.Background(), "parent-sha-123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sha != "parent-sha-123" { + t.Errorf("expected sha 'parent-sha-123', got %q", sha) + } + }) + + t.Run("branch point does not exist", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + json.NewEncoder(w).Encode(map[string]string{ + "message": "Reference does not exist", + }) + })) + defer server.Close() + + client := newTestClient(t, server) + _, err := client.CreateBranch(context.Background(), "nonexistent-sha") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "422") { + t.Errorf("expected error to mention 422, got %q", err.Error()) + } + }) +} + +func TestPushChange(t *testing.T) { + t.Run("dry run returns zero hash", func(t *testing.T) { + client := &Client{ + owner: "test-owner", + repo: "test-repo", + branch: "test-branch", + dryrun: true, + } + + change := Change{ + hash: "abc123", + message: "Test commit", + entries: map[string][]byte{ + "file.txt": []byte("content"), + }, + } + + sha, err := client.PushChange(context.Background(), "head-sha", change) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should return zeros with same length as input hash + if sha != "000000" { + t.Errorf("expected '000000', got %q", sha) + } + }) + + t.Run("successful push with file addition", func(t *testing.T) { + var ( + getCommitCalled bool + createBlobCalled bool + createTreeCalled bool + commitCalled bool + updateRefCalled bool + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/commits/"): + getCommitCalled = true + json.NewEncoder(w).Encode(github.Commit{ + SHA: github.Ptr("parent-sha"), + Tree: &github.Tree{ + SHA: github.Ptr("parent-tree-sha"), + }, + }) + + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/blobs": + createBlobCalled = true + var blob github.Blob + json.NewDecoder(r.Body).Decode(&blob) + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Blob{ + SHA: github.Ptr("blob-sha-123"), + }) + + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/trees": + createTreeCalled = true + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Tree{ + SHA: github.Ptr("new-tree-sha"), + }) + + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/commits": + commitCalled = true + var commit github.Commit + json.NewDecoder(r.Body).Decode(&commit) + if commit.GetMessage() != "Test commit" { + t.Errorf("unexpected commit message: %s", commit.GetMessage()) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Commit{ + SHA: github.Ptr("new-commit-sha"), + }) + + case r.Method == http.MethodPatch && strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/refs/"): + updateRefCalled = true + json.NewEncoder(w).Encode(github.Reference{ + Object: &github.GitObject{ + SHA: github.Ptr("new-commit-sha"), + }, + }) + + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + client := newTestClient(t, server) + change := Change{ + hash: "local-hash", + message: "Test commit", + entries: map[string][]byte{ + "file.txt": []byte("content"), + }, + } + + sha, err := client.PushChange(context.Background(), "parent-sha", change) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sha != "new-commit-sha" { + t.Errorf("expected 'new-commit-sha', got %q", sha) + } + + if !getCommitCalled { + t.Error("GetCommit was not called") + } + if !createBlobCalled { + t.Error("CreateBlob was not called") + } + if !createTreeCalled { + t.Error("CreateTree was not called") + } + if !commitCalled { + t.Error("CreateCommit was not called") + } + if !updateRefCalled { + t.Error("UpdateRef was not called") + } + }) + + t.Run("successful push with file deletion", func(t *testing.T) { + var treeEntries []*github.TreeEntry + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/commits/"): + json.NewEncoder(w).Encode(github.Commit{ + SHA: github.Ptr("parent-sha"), + Tree: &github.Tree{ + SHA: github.Ptr("parent-tree-sha"), + }, + }) + + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/trees": + var req struct { + BaseTree string `json:"base_tree"` + Tree []*github.TreeEntry `json:"tree"` + } + json.NewDecoder(r.Body).Decode(&req) + treeEntries = req.Tree + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Tree{ + SHA: github.Ptr("new-tree-sha"), + }) + + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/commits": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Commit{ + SHA: github.Ptr("new-commit-sha"), + }) + + case r.Method == http.MethodPatch && strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/refs/"): + json.NewEncoder(w).Encode(github.Reference{ + Object: &github.GitObject{ + SHA: github.Ptr("new-commit-sha"), + }, + }) + + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + client := newTestClient(t, server) + change := Change{ + hash: "local-hash", + message: "Delete file", + entries: map[string][]byte{ + "deleted-file.txt": nil, // nil means deletion + }, + } + + _, err := client.PushChange(context.Background(), "parent-sha", change) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify that the tree entry for deletion has no SHA (which signals deletion) + if len(treeEntries) != 1 { + t.Fatalf("expected 1 tree entry, got %d", len(treeEntries)) + } + if treeEntries[0].GetPath() != "deleted-file.txt" { + t.Errorf("expected path 'deleted-file.txt', got %q", treeEntries[0].GetPath()) + } + // For deletions, SHA should be nil/empty + if treeEntries[0].SHA != nil && *treeEntries[0].SHA != "" { + t.Errorf("expected nil/empty SHA for deletion, got %q", *treeEntries[0].SHA) + } + }) + + t.Run("commit with body", func(t *testing.T) { + var receivedMessage string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/commits/"): + json.NewEncoder(w).Encode(github.Commit{ + Tree: &github.Tree{SHA: github.Ptr("tree-sha")}, + }) + + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/blobs": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Blob{SHA: github.Ptr("blob-sha")}) + + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/trees": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Tree{SHA: github.Ptr("tree-sha")}) + + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/commits": + var commit github.Commit + json.NewDecoder(r.Body).Decode(&commit) + receivedMessage = commit.GetMessage() + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Commit{SHA: github.Ptr("commit-sha")}) + + case r.Method == http.MethodPatch: + json.NewEncoder(w).Encode(github.Reference{ + Object: &github.GitObject{SHA: github.Ptr("commit-sha")}, + }) + } + })) + defer server.Close() + + client := newTestClient(t, server) + change := Change{ + hash: "local", + message: "Headline\n\nThis is the body\nwith multiple lines", + entries: map[string][]byte{"file.txt": []byte("x")}, + } + + _, err := client.PushChange(context.Background(), "parent", change) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(receivedMessage, "Headline") { + t.Errorf("message should contain headline, got %q", receivedMessage) + } + if !strings.Contains(receivedMessage, "This is the body") { + t.Errorf("message should contain body, got %q", receivedMessage) + } + }) + + t.Run("get parent commit fails", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + client := newTestClient(t, server) + change := Change{ + hash: "local", + message: "Test", + entries: map[string][]byte{"file.txt": []byte("x")}, + } + + _, err := client.PushChange(context.Background(), "nonexistent", change) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "get parent commit") { + t.Errorf("expected 'get parent commit' error, got %q", err.Error()) + } + }) + + t.Run("create blob fails", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/commits/") { + json.NewEncoder(w).Encode(github.Commit{ + Tree: &github.Tree{SHA: github.Ptr("tree-sha")}, + }) + return + } + if r.URL.Path == "/repos/test-owner/test-repo/git/blobs" { + w.WriteHeader(http.StatusInternalServerError) + return + } + })) + defer server.Close() + + client := newTestClient(t, server) + change := Change{ + hash: "local", + message: "Test", + entries: map[string][]byte{"file.txt": []byte("x")}, + } + + _, err := client.PushChange(context.Background(), "parent", change) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "create blob") { + t.Errorf("expected 'create blob' error, got %q", err.Error()) + } + }) + + t.Run("create tree fails", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/commits/"): + json.NewEncoder(w).Encode(github.Commit{ + Tree: &github.Tree{SHA: github.Ptr("tree-sha")}, + }) + case r.URL.Path == "/repos/test-owner/test-repo/git/blobs": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Blob{SHA: github.Ptr("blob-sha")}) + case r.URL.Path == "/repos/test-owner/test-repo/git/trees": + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer server.Close() + + client := newTestClient(t, server) + change := Change{ + hash: "local", + message: "Test", + entries: map[string][]byte{"file.txt": []byte("x")}, + } + + _, err := client.PushChange(context.Background(), "parent", change) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "create tree") { + t.Errorf("expected 'create tree' error, got %q", err.Error()) + } + }) + + t.Run("create commit fails", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/commits/"): + json.NewEncoder(w).Encode(github.Commit{ + Tree: &github.Tree{SHA: github.Ptr("tree-sha")}, + }) + case r.URL.Path == "/repos/test-owner/test-repo/git/blobs": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Blob{SHA: github.Ptr("blob-sha")}) + case r.URL.Path == "/repos/test-owner/test-repo/git/trees": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Tree{SHA: github.Ptr("tree-sha")}) + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/commits": + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer server.Close() + + client := newTestClient(t, server) + change := Change{ + hash: "local", + message: "Test", + entries: map[string][]byte{"file.txt": []byte("x")}, + } + + _, err := client.PushChange(context.Background(), "parent", change) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "create commit") { + t.Errorf("expected 'create commit' error, got %q", err.Error()) + } + }) + + t.Run("update ref fails", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/commits/"): + json.NewEncoder(w).Encode(github.Commit{ + Tree: &github.Tree{SHA: github.Ptr("tree-sha")}, + }) + case r.URL.Path == "/repos/test-owner/test-repo/git/blobs": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Blob{SHA: github.Ptr("blob-sha")}) + case r.URL.Path == "/repos/test-owner/test-repo/git/trees": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Tree{SHA: github.Ptr("tree-sha")}) + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/commits": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Commit{SHA: github.Ptr("commit-sha")}) + case r.Method == http.MethodPatch: + w.WriteHeader(http.StatusConflict) + } + })) + defer server.Close() + + client := newTestClient(t, server) + change := Change{ + hash: "local", + message: "Test", + entries: map[string][]byte{"file.txt": []byte("x")}, + } + + _, err := client.PushChange(context.Background(), "parent", change) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "update ref") { + t.Errorf("expected 'update ref' error, got %q", err.Error()) + } + }) +} + +func TestPushChanges(t *testing.T) { + t.Run("multiple changes", func(t *testing.T) { + commitCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/commits/"): + json.NewEncoder(w).Encode(github.Commit{ + Tree: &github.Tree{SHA: github.Ptr("tree-sha")}, + }) + case r.URL.Path == "/repos/test-owner/test-repo/git/blobs": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Blob{SHA: github.Ptr("blob-sha")}) + case r.URL.Path == "/repos/test-owner/test-repo/git/trees": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Tree{SHA: github.Ptr("tree-sha")}) + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/commits": + commitCount++ + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Commit{SHA: github.Ptr("commit-sha-" + string(rune('0'+commitCount)))}) + case r.Method == http.MethodPatch: + json.NewEncoder(w).Encode(github.Reference{ + Object: &github.GitObject{SHA: github.Ptr("final-sha")}, + }) + } + })) + defer server.Close() + + client := newTestClient(t, server) + changes := []Change{ + {hash: "h1", message: "First", entries: map[string][]byte{"a.txt": []byte("a")}}, + {hash: "h2", message: "Second", entries: map[string][]byte{"b.txt": []byte("b")}}, + {hash: "h3", message: "Third", entries: map[string][]byte{"c.txt": []byte("c")}}, + } + + count, sha, err := client.PushChanges(context.Background(), "initial", changes...) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if count != 3 { + t.Errorf("expected count 3, got %d", count) + } + if sha == "" { + t.Error("expected non-empty sha") + } + if commitCount != 3 { + t.Errorf("expected 3 commits to be created, got %d", commitCount) + } + }) + + t.Run("failure on second change", func(t *testing.T) { + commitCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/commits/"): + json.NewEncoder(w).Encode(github.Commit{ + Tree: &github.Tree{SHA: github.Ptr("tree-sha")}, + }) + case r.URL.Path == "/repos/test-owner/test-repo/git/blobs": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Blob{SHA: github.Ptr("blob-sha")}) + case r.URL.Path == "/repos/test-owner/test-repo/git/trees": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Tree{SHA: github.Ptr("tree-sha")}) + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/commits": + commitCount++ + if commitCount == 2 { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Commit{SHA: github.Ptr("commit-sha")}) + case r.Method == http.MethodPatch: + json.NewEncoder(w).Encode(github.Reference{ + Object: &github.GitObject{SHA: github.Ptr("sha")}, + }) + } + })) + defer server.Close() + + client := newTestClient(t, server) + changes := []Change{ + {hash: "h1", message: "First", entries: map[string][]byte{"a.txt": []byte("a")}}, + {hash: "h2", message: "Second", entries: map[string][]byte{"b.txt": []byte("b")}}, + {hash: "h3", message: "Third", entries: map[string][]byte{"c.txt": []byte("c")}}, + } + + count, _, err := client.PushChanges(context.Background(), "initial", changes...) + if err == nil { + t.Fatal("expected error, got nil") + } + if count != 2 { + t.Errorf("expected count 2 (failed on second), got %d", count) + } + }) +} + +func TestURLHelpers(t *testing.T) { + client := &Client{ + owner: "myorg", + repo: "myrepo", + branch: "feature-branch", + } + + t.Run("browseCommitsURL", func(t *testing.T) { + url := client.browseCommitsURL() + expected := "https://github.com/myorg/myrepo/commits/feature-branch" + if url != expected { + t.Errorf("expected %q, got %q", expected, url) + } + }) + + t.Run("commitURL", func(t *testing.T) { + url := client.commitURL("abc123") + expected := "https://github.com/myorg/myrepo/commit/abc123" + if url != expected { + t.Errorf("expected %q, got %q", expected, url) + } + }) +} diff --git a/go.mod b/go.mod index 884ea09..8f8725d 100644 --- a/go.mod +++ b/go.mod @@ -4,5 +4,8 @@ go 1.24.10 require ( github.com/alecthomas/kong v1.11.0 + github.com/google/go-github/v81 v81.0.0 golang.org/x/oauth2 v0.30.0 ) + +require github.com/google/go-querystring v1.1.0 // indirect diff --git a/go.sum b/go.sum index 211edf3..d9c1302 100644 --- a/go.sum +++ b/go.sum @@ -4,7 +4,15 @@ github.com/alecthomas/kong v1.11.0 h1:y++1gI7jf8O7G7l4LZo5ASFhrhJvzc+WgF/arranEm github.com/alecthomas/kong v1.11.0/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v81 v81.0.0 h1:hTLugQRxSLD1Yei18fk4A5eYjOGLUBKAl/VCqOfFkZc= +github.com/google/go-github/v81 v81.0.0/go.mod h1:upyjaybucIbBIuxgJS7YLOZGziyvvJ92WX6WEBNE3sM= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From de4940d8689ba676e5a71d8519e0a50b9e24f0be Mon Sep 17 00:00:00 2001 From: Alex Vidal Date: Fri, 23 Jan 2026 12:17:09 -0600 Subject: [PATCH 03/14] feat(push)!: simplify push command to not take input Specifying which commits to push started to become untenable. In an effort to simplify, push now no longer takes any non-flag arguments. Instead, it will always push local commits that do not exist on the remote. Note that `--head-sha` is still recommended to be used as a safety check to ensure the remote has not moved from where you expected. If the remote HEAD is not an ancestor of the local HEAD, the push will fail due to diverged history. --- .github/workflows/release.yml | 1 - README.md | 39 +++++++++------ action-template/README.md | 23 ++------- action-template/action.js | 4 +- action-template/action.yml | 2 - cmd_push.go | 89 ++++++++++++++++++++++++++--------- git.go | 48 +++++++++++++++---- git_test.go | 80 +++++++++++++++++++++++++++++++ pushchanges.go | 3 ++ stdin.go | 70 --------------------------- stdin_test.go | 82 -------------------------------- 11 files changed, 216 insertions(+), 225 deletions(-) delete mode 100644 stdin.go delete mode 100644 stdin_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9057764..0e8e746 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -84,7 +84,6 @@ jobs: with: branch: action command: push - commits: ${{ steps.create-commit.outputs.sha }} - name: check release tag id: check-tag diff --git a/README.md b/README.md index 6bc0e1b..1b5b74e 100644 --- a/README.md +++ b/README.md @@ -67,23 +67,27 @@ Example: `commit-headless [flags...] --head-sha=$(git rev-parse main H ### commit-headless push -In addition to the required target and branch flags, the `push` command expects a list of commit -hashes as arguments *or* a list of commit hashes *in reverse chronological order (newest first)* -on standard input. +The `push` command automatically determines which local commits need to be pushed by comparing +local HEAD with the remote branch HEAD. It then iterates over those commits, extracts the changed +files and commit message, and creates corresponding remote commits. -It will iterate over the supplied commits, extract the set of changed files and commit message, then -craft new *remote* commits corresponding to each local commit. - -The remote commit will have the original commit message, with "Co-authored-by" trailer for the +The remote commits will have the original commit message, with a "Co-authored-by" trailer for the original commit author. -You can use `commit-headless push` via: +Basic usage: + + # Push local commits to an existing remote branch + commit-headless push -T owner/repo --branch feature - commit-headless push [flags...] HASH1 HASH2 HASH3 ... + # Push with a safety check that remote HEAD matches expected value + commit-headless push -T owner/repo --branch feature --head-sha abc123 -Or, using git log (note `--oneline`): + # Create a new branch and push local commits to it + commit-headless push -T owner/repo --branch new-feature --head-sha abc123 --create-branch - git log --oneline main.. | commit-headless push [flags...] +**Note:** The remote HEAD (or `--head-sha` when creating a branch) must be an ancestor of local +HEAD. If the histories have diverged, the push will fail with an error. This ensures you don't +accidentally create broken history when the local checkout is out of sync with the remote. ### commit-headless commit @@ -111,7 +115,7 @@ You can easily try `commit-headless` locally. Create a commit with a different a demonstrate how commit-headless attributes changes to the original author), and run it with a GitHub token. -For example, create a commit locally and push it to a new branch using the current branch as the +For example, create a commit locally and push it to a new branch using the parent commit as the branch point: ``` @@ -123,9 +127,14 @@ git commit --author='A U Thor ' --message="test bot commit" commit-headless push \ --target=owner/repo \ --branch=bot-branch \ - --head-sha="$(git rev-parse HEAD^)" \ # use the previous commit as our branch point - --create-branch \ - "$(git rev-parse HEAD)" # push the commit we just created + --head-sha="$(git rev-parse HEAD^)" \ + --create-branch +``` + +Or, to push to an existing branch: + +``` +commit-headless push --target=owner/repo --branch=existing-branch ``` ## Action Releases diff --git a/action-template/README.md b/action-template/README.md index b8e7e11..b81e750 100644 --- a/action-template/README.md +++ b/action-template/README.md @@ -10,12 +10,11 @@ For more details on how `commit-headless` works, check the main branch link abov ## Usage (commit-headless push) -If your workflow creates multiple commits and you want to push all of them, you can use -`commit-headless push`: +The `push` command automatically determines which local commits need to be pushed by comparing +local HEAD with the remote branch HEAD. ``` - name: Create commits - id: create-commits run: | git config --global user.name "A U Thor" git config --global user.email "author@example.com" @@ -26,16 +25,6 @@ If your workflow creates multiple commits and you want to push all of them, you echo "another commit" >> bot.txt git add bot.txt && git commit -m"bot commit 2" - # List both commit hashes in reverse order, space separated - echo "commits=\"$(git log "${{ github.sha }}".. --format='%H' | tr '\n' ' ')\"" >> $GITHUB_OUTPUT - - # If you just have a single commit, you can do something like: - # echo "commit=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT - # and then use it in the action via: - # with: - # ... - # commits: ${{ steps.create-commits.outputs.commit }} - - name: Push commits uses: DataDog/commit-headless@action/v%%VERSION%% with: @@ -43,7 +32,6 @@ If your workflow creates multiple commits and you want to push all of them, you target: ${{ github.repository }} # default branch: ${{ github.ref_name }} command: push - commits: "${{ steps.create-commits.outputs.commits }}" ``` If you primarily create commits on *new* branches, you'll want to use the `create-branch` option. This @@ -52,7 +40,6 @@ example creates a commit with the current time in a file, and then pushes it to ``` - name: Create commits - id: create-commits run: | git config --global user.name "A U Thor" git config --global user.email "author@example.com" @@ -60,9 +47,6 @@ example creates a commit with the current time in a file, and then pushes it to echo "BUILD-TIMESTAMP-RFC3339: $(date --rfc-3339=s)" > last-build.txt git add last-build.txt && git commit -m"update build timestamp" - # Store the created commit as a step output - echo "commit=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT - - name: Push commits uses: DataDog/commit-headless@action/v%%VERSION%% with: @@ -70,7 +54,6 @@ example creates a commit with the current time in a file, and then pushes it to head-sha: ${{ github.sha }} create-branch: true command: push - commits: "${{ steps.create-commits.outputs.commit }}" ``` ## Usage (commit-headless commit) @@ -106,6 +89,6 @@ single commit out of them. For that, you can use `commit-headless commit`: author: "A U Thor " # defaults to the github-actions bot account message: "a commit message" command: commit - files: "${{ steps.create-commits.outputs.files }}" + files: "${{ steps.change-files.outputs.files }}" force: true # default false, needs to be true to allow deletion ``` diff --git a/action-template/action.js b/action-template/action.js index d0fa603..fd1976b 100644 --- a/action-template/action.js +++ b/action-template/action.js @@ -67,9 +67,7 @@ function main() { if(dryrun.toLowerCase() === "true") { args.push("--dry-run") } - if (command === "push") { - args.push(...process.env.INPUT_COMMITS.split(/\s+/)); - } else { + if (command === "commit") { const author = process.env["INPUT_AUTHOR"] || ""; const message = process.env["INPUT_MESSAGE"] || ""; if(author !== "") { args.push("--author", author) } diff --git a/action-template/action.yml b/action-template/action.yml index 58399f0..e3d09d7 100644 --- a/action-template/action.yml +++ b/action-template/action.yml @@ -32,8 +32,6 @@ inputs: dry-run: description: 'Stop processing just before actually making changes to the remote. Note that the pushed_ref output will be a zeroed commit hash.' default: false - commits: - description: 'For push, the list of commit hashes to push, oldest first' files: description: 'For commit, the list of files to include in the commit' force: diff --git a/cmd_push.go b/cmd_push.go index 85d205e..8adf72e 100644 --- a/cmd_push.go +++ b/cmd_push.go @@ -8,15 +8,13 @@ import ( type PushCmd struct { remoteFlags - RepoPath string `name:"repo-path" default:"." help:"Path to the repository that contains the commits. Defaults to the current directory."` - Commits []string `arg:"" optional:"" help:"Commit hashes to be applied to the target. Defaults to reading a list of commit hashes from standard input."` + RepoPath string `name:"repo-path" default:"." help:"Path to the repository that contains the commits. Defaults to the current directory."` } func (c *PushCmd) Help() string { return ` -This command should be run when you have commits created locally that you'd like to push to the -remote. You can pass the commit hashes either as space-separated arguments or over standard input -with one commit hash per line. +This command pushes local commits that don't exist on the remote branch. It automatically determines +which commits need to be pushed by comparing local HEAD with the remote branch HEAD. You must provide a GitHub token via the environment in one of the following variables, in preference order: @@ -28,16 +26,24 @@ order: On a successful push, the hash of the last commit pushed will be printed to standard output, allowing you to capture it in a script. All other output is printed to standard error. -For example, to push the most recent three commits: +Example usage: - commit-headless push -T owner/repo --branch branch HEAD HEAD^ HEAD^^ + # Push local commits to an existing remote branch + commit-headless push -T owner/repo --branch feature -Or, to push all commits on the current branch that aren't on the main branch: + # Create a new branch from a specific commit and push local commits + commit-headless push -T owner/repo --branch new-feature --head-sha abc123 --create-branch - git log --oneline main.. | commit-headless push -T owner/repo --branch branch + # Push with a safety check that remote HEAD matches expected value + commit-headless push -T owner/repo --branch feature --head-sha abc123 -When reading commit hashes from standard input, the only requirement is that the commit hash is at -the start of the line, and any other content is separated by at least one whitespace character. +When --head-sha is provided without --create-branch, it acts as a safety check: the push will fail +if the remote branch HEAD doesn't match the expected value. This prevents accidentally overwriting +commits that were pushed after your workflow started. + +The remote HEAD (or --head-sha when creating a branch) must be an ancestor of local HEAD. If the +histories have diverged, the push will fail. This prevents creating broken history when the local +checkout is out of sync with the remote. Note that the pushed commits will not share the same commit sha, and you should avoid operating on the local checkout after running this command. @@ -51,23 +57,62 @@ pushed commits, you should hard reset the local checkout to the remote version a } func (c *PushCmd) Run() error { - if len(c.Commits) == 0 { - var err error - c.Commits, err = commitsFromStdin(os.Stdin) - if err != nil { - return err - } + ctx := context.Background() + repo := &Repository{path: c.RepoPath} + owner, repository := c.Target.Owner(), c.Target.Repository() + + // Determine the base commit (remote HEAD or --head-sha for new branches) + baseCommit, err := c.getBaseCommit(ctx, owner, repository) + if err != nil { + return err } - // Convert c.Commits into []Change which we can feed to the remote - repo := &Repository{path: c.RepoPath} + // Find local commits that aren't on the remote + commits, err := repo.CommitsSince(baseCommit) + if err != nil { + return err + } - changes, err := repo.Changes(c.Commits...) + if len(commits) == 0 { + log("No local commits to push (local HEAD matches remote HEAD %s)\n", baseCommit) + return nil + } + + changes, err := repo.Changes(commits...) if err != nil { return fmt.Errorf("get changes: %w", err) } - owner, repository := c.Target.Owner(), c.Target.Repository() + return pushChanges(ctx, owner, repository, c.Branch, c.HeadSha, c.CreateBranch, c.DryRun, changes...) +} + +// getBaseCommit returns the commit to use as the base for determining what to push. +// For new branches (--create-branch), this is --head-sha. +// For existing branches, this is the remote HEAD (validated against --head-sha if provided). +func (c *PushCmd) getBaseCommit(ctx context.Context, owner, repository string) (string, error) { + if c.CreateBranch { + if c.HeadSha == "" { + return "", fmt.Errorf("--create-branch requires --head-sha to specify the branch point") + } + return c.HeadSha, nil + } + + // Get the remote branch HEAD + token := getToken(os.Getenv) + if token == "" { + return "", fmt.Errorf("no GitHub token supplied") + } + + client := NewClient(ctx, token, owner, repository, c.Branch) + remoteHead, err := client.GetHeadCommitHash(ctx) + if err != nil { + return "", fmt.Errorf("get remote HEAD: %w", err) + } + + // If --head-sha was provided, validate it matches the remote + if c.HeadSha != "" && c.HeadSha != remoteHead { + return "", fmt.Errorf("remote HEAD %s doesn't match expected --head-sha %s (the branch may have been updated)", remoteHead, c.HeadSha) + } - return pushChanges(context.Background(), owner, repository, c.Branch, c.HeadSha, c.CreateBranch, c.DryRun, changes...) + return remoteHead, nil } diff --git a/git.go b/git.go index 8fb51e3..4ec7bd7 100644 --- a/git.go +++ b/git.go @@ -12,7 +12,43 @@ type Repository struct { path string } -// Returns a Change for each supplied commit +// CommitsSince returns the commits between base and HEAD, oldest first. +// This is equivalent to `git rev-list --reverse base..HEAD`. +// Returns an error if base is not an ancestor of HEAD. +func (r *Repository) CommitsSince(base string) ([]string, error) { + // First verify that base is an ancestor of HEAD + cmd := exec.Command("git", "merge-base", "--is-ancestor", base, "HEAD") + cmd.Dir = r.path + if err := cmd.Run(); err != nil { + if _, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("remote HEAD %s is not an ancestor of local HEAD (histories have diverged)", base) + } + return nil, fmt.Errorf("check ancestry: %w", err) + } + + cmd = exec.Command("git", "rev-list", "--reverse", base+"..HEAD") + cmd.Dir = r.path + out, err := cmd.Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("list commits: %s", strings.TrimSpace(string(ee.Stderr))) + } + return nil, fmt.Errorf("list commits: %w", err) + } + + var commits []string + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + commits = append(commits, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return nil, err + } + + return commits, nil +} + +// Returns a Change for each supplied commit hash func (r *Repository) Changes(commits ...string) ([]Change, error) { changes := make([]Change, len(commits)) for i, h := range commits { @@ -25,16 +61,8 @@ func (r *Repository) Changes(commits ...string) ([]Change, error) { return changes, nil } -// Returns a Change for the specific commit +// Returns a Change for the specific commit hash func (r *Repository) changed(commit string) (Change, error) { - // First, make sure the commit looks like a commit hash - // While technically all of our calls would work with references such as HEAD, - // refs/heads/branch, refs/tags/etc we're going to require callers provide things that look like - // commits. - if !hashRegex.MatchString(commit) { - return Change{}, fmt.Errorf("commit %q does not look like a commit, should be at least 4 hexadecimal digits.", commit) - } - parents, author, message, err := r.catfile(commit) if err != nil { return Change{}, err diff --git a/git_test.go b/git_test.go index 01884e4..05aad6c 100644 --- a/git_test.go +++ b/git_test.go @@ -95,6 +95,86 @@ func TestCommitHashes(t *testing.T) { } } +func TestCommitsSince(t *testing.T) { + tr := testRepo(t) + + // Create a few commits + requireNoError(t, os.WriteFile(tr.path("file1"), []byte("content1"), 0o644)) + tr.git("add", "-A") + tr.git("commit", "--message", "first commit") + hash1 := strings.TrimSpace(string(tr.git("rev-parse", "HEAD"))) + + requireNoError(t, os.WriteFile(tr.path("file2"), []byte("content2"), 0o644)) + tr.git("add", "-A") + tr.git("commit", "--message", "second commit") + hash2 := strings.TrimSpace(string(tr.git("rev-parse", "HEAD"))) + + requireNoError(t, os.WriteFile(tr.path("file3"), []byte("content3"), 0o644)) + tr.git("add", "-A") + tr.git("commit", "--message", "third commit") + hash3 := strings.TrimSpace(string(tr.git("rev-parse", "HEAD"))) + + r := &Repository{path: tr.root} + + t.Run("commits since first", func(t *testing.T) { + commits, err := r.CommitsSince(hash1) + requireNoError(t, err) + if len(commits) != 2 { + t.Fatalf("expected 2 commits, got %d: %v", len(commits), commits) + } + if commits[0] != hash2 || commits[1] != hash3 { + t.Errorf("expected [%s, %s], got %v", hash2, hash3, commits) + } + }) + + t.Run("commits since second", func(t *testing.T) { + commits, err := r.CommitsSince(hash2) + requireNoError(t, err) + if len(commits) != 1 { + t.Fatalf("expected 1 commit, got %d: %v", len(commits), commits) + } + if commits[0] != hash3 { + t.Errorf("expected [%s], got %v", hash3, commits) + } + }) + + t.Run("commits since HEAD (none)", func(t *testing.T) { + commits, err := r.CommitsSince(hash3) + requireNoError(t, err) + if len(commits) != 0 { + t.Errorf("expected no commits, got %v", commits) + } + }) + + t.Run("invalid base", func(t *testing.T) { + _, err := r.CommitsSince("nonexistent-ref-12345") + if err == nil { + t.Error("expected error for invalid reference") + } + }) + + t.Run("diverged history", func(t *testing.T) { + // Create a separate branch with different history + tr.git("checkout", "-b", "other-branch", hash1) + requireNoError(t, os.WriteFile(tr.path("other-file"), []byte("other"), 0o644)) + tr.git("add", "-A") + tr.git("commit", "--message", "commit on other branch") + otherHash := strings.TrimSpace(string(tr.git("rev-parse", "HEAD"))) + + // Go back to main branch + tr.git("checkout", "-") + + // otherHash is not an ancestor of HEAD (hash3) + _, err := r.CommitsSince(otherHash) + if err == nil { + t.Error("expected error for diverged history") + } + if !strings.Contains(err.Error(), "not an ancestor") { + t.Errorf("expected 'not an ancestor' error, got: %v", err) + } + }) +} + func TestChangedFiles(t *testing.T) { // First, prep the test repository tr := testRepo(t) diff --git a/pushchanges.go b/pushchanges.go index 7861066..5b9d86a 100644 --- a/pushchanges.go +++ b/pushchanges.go @@ -5,9 +5,12 @@ import ( "errors" "fmt" "os" + "regexp" "strings" ) +var hashRegex = regexp.MustCompile(`^[a-f0-9]{4,40}$`) + // Takes a list of changes to push to the remote identified by target. // Prints the last commit pushed to standard output. func pushChanges(ctx context.Context, owner, repository, branch, headSha string, createBranch, dryrun bool, changes ...Change) error { diff --git a/stdin.go b/stdin.go deleted file mode 100644 index f8098eb..0000000 --- a/stdin.go +++ /dev/null @@ -1,70 +0,0 @@ -package main - -import ( - "bufio" - "errors" - "io" - "io/fs" - "os" - "regexp" - "slices" - "strings" -) - -type stattable interface { - Stat() (fs.FileInfo, error) -} - -var ( - errUnpipedStdin = errors.New("could not read from non-piped standard input") - errStatStdin = errors.New("could not read commits from standard input") - errNoCommitsStdin = errors.New("no commits present on standard input") -) - -var hashRegex = regexp.MustCompile(`^[a-f0-9]{4,40}$`) - -// reads a list of commit hashes from r, which is typically stdin, returning the commit hashes in -// reverse order -func commitsFromStdin(r io.Reader) ([]string, error) { - // if r is stattable (like os.Stdin), make sure it's a pipe - if stdin, ok := r.(stattable); ok { - fi, err := stdin.Stat() - if err != nil { - return nil, errStatStdin - } - - if fi.Mode()&os.ModeNamedPipe == 0 { - return nil, errUnpipedStdin - } - } - - commits := []string{} - scanner := bufio.NewScanner(r) - for scanner.Scan() { - ln := scanner.Text() - fs := strings.Fields(ln) - - // the only time strings.Fields returns an empty slice is when the input is only spaces - // so we'll just continue - if len(fs) == 0 { - continue - } - - if hashRegex.MatchString(fs[0]) { - commits = append(commits, fs[0]) - } - } - - if err := scanner.Err(); err != nil { - return nil, err - } - - if len(commits) == 0 { - return nil, errNoCommitsStdin - } - - // reverse the commits since log output is newest first - // TODO: Should this be detected by commit time instead? - slices.Reverse(commits) - return commits, nil -} diff --git a/stdin_test.go b/stdin_test.go deleted file mode 100644 index d64c667..0000000 --- a/stdin_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package main - -import ( - "bytes" - "io" - "io/fs" - "slices" - "testing" - "testing/fstest" -) - -func TestCommitsFromStdin(t *testing.T) { - fakefs := fstest.MapFS{ - "unpiped": &fstest.MapFile{}, - "piped": &fstest.MapFile{ - Data: []byte("aaaa\nbbbb"), - Mode: fs.FileMode(0) | fs.ModeNamedPipe, - }, - } - - fakefile := func(name string) fs.File { - t.Helper() - out, err := fakefs.Open(name) - if err != nil { - t.Error(err) - t.FailNow() - } - return out - } - - testcases := []struct { - name string - in io.Reader - want []string - wantErr error - }{{ - name: "default", - in: bytes.NewBufferString("deadbeef\nabba"), - want: []string{"abba", "deadbeef"}, - }, { - name: "mixed input", - in: bytes.NewBufferString("deadbeef\nnot-a-commit-hash\nabba"), - want: []string{"abba", "deadbeef"}, - }, { - name: "stuff after the hash", - in: bytes.NewBufferString("deadbeef (test-branch) feat: hello\nnot-a-commit-hash\nabba"), - want: []string{"abba", "deadbeef"}, - }, { - name: "not a pipe", - in: fakefile("unpiped"), - wantErr: errUnpipedStdin, - }, { - name: "piped", - in: fakefile("piped"), - want: []string{"bbbb", "aaaa"}, - }, { - name: "no commits", - in: bytes.NewBufferString("not\na\ncommit"), - wantErr: errNoCommitsStdin, - }, { - name: "only spaces", - in: bytes.NewBufferString(" "), - wantErr: errNoCommitsStdin, - }, { - name: "blank lines", - in: bytes.NewBufferString("abcd\n \ndead"), - want: []string{"dead", "abcd"}, - }} - - for _, tc := range testcases { - t.Run(tc.name, func(t *testing.T) { - got, err := commitsFromStdin(tc.in) - if err != tc.wantErr { - t.Fatalf("expected error; got=%v want=%v", err, tc.wantErr) - } - - if !slices.Equal(got, tc.want) { - t.Fatalf("expected equal hashes; got=%v want=%v", got, tc.want) - } - }) - } -} From 670ae34c47bb1d1af4b17d54e769c045fbefd53b Mon Sep 17 00:00:00 2001 From: Alex Vidal Date: Fri, 23 Jan 2026 12:27:06 -0600 Subject: [PATCH 04/14] feat(commit)!: simplify commit command, no more specifying files Similar to the push command simplification, the commit command had some bad UX. The original intent was to allow folks to use `commit-headless commit` when they didn't even have the repository itself checked out, but in practice that hasn't been seen (consider that your changes tend to be in CI jobs where the code is available). Instead of requiring users to supply the list of changed files, the `commit` command now operates like `git commit && git push` and takes whatever changes have been staged. --- README.md | 40 ++++++++++---- action-template/README.md | 55 ++++++++++++------- action-template/action.js | 10 ---- action-template/action.yml | 5 -- cmd_commit.go | 110 ++++++++++++++++++++----------------- git.go | 53 ++++++++++++++++++ git_test.go | 74 +++++++++++++++++++++++++ 7 files changed, 251 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index 1b5b74e..dff59e0 100644 --- a/README.md +++ b/README.md @@ -91,23 +91,39 @@ accidentally create broken history when the local checkout is out of sync with t ### commit-headless commit -This command is more geared for creating single commits at a time. It takes a list of files to -commit changes to, and those files will either be updated/added or deleted in a single commit. +The `commit` command creates a single commit on the remote from the currently staged changes, +similar to how `git commit` works. Stage your changes first with `git add`, then run this command +to push them as a signed commit on the remote. -Note that you cannot delete a file without also adding `--force` for safety reasons. +The staged file paths must match the paths on the remote. That is, if you stage "path/to/file.txt" +then the contents of that file will be applied to that same path on the remote. -Usage example: +Staged deletions (`git rm`) are also supported. - # Commit changes to these two files - commit-headless commit [flags...] -- README.md .gitlab-ci.yml +Unlike `push`, the `commit` command does not require any relationship between local and remote +history. This makes it useful for broadcasting the same file changes to multiple repositories, +even if they have completely unrelated histories: - # Remove a file, add another one, and commit - rm file/i/do/not/want - echo "hello" > hi-there.txt - commit-headless commit [flags...] --force -- hi-there.txt file/i/do/not/want + # Apply the same changes to multiple repositories + git add config.yml security-policy.md + commit-headless commit -T org/repo1 --branch main -m "Update security policy" + commit-headless commit -T org/repo2 --branch main -m "Update security policy" + commit-headless commit -T org/repo3 --branch main -m "Update security policy" - # Commit a change with a custom message - commit-headless commit [flags...] -m"ran a pipeline" -- output.txt +Basic usage: + + # Stage changes and commit to remote + git add README.md .gitlab-ci.yml + commit-headless commit -T owner/repo --branch feature -m "Update docs" + + # Stage a deletion and a new file + git rm old-file.txt + git add new-file.txt + commit-headless commit -T owner/repo --branch feature -m "Replace old with new" + + # Stage all changes and commit + git add -A + commit-headless commit -T owner/repo --branch feature -m "Update everything" ## Try it! diff --git a/action-template/README.md b/action-template/README.md index b81e750..d59bf77 100644 --- a/action-template/README.md +++ b/action-template/README.md @@ -58,29 +58,21 @@ example creates a commit with the current time in a file, and then pushes it to ## Usage (commit-headless commit) -Some workflows may just have a specific set of files that they change and just want to create a -single commit out of them. For that, you can use `commit-headless commit`: +The `commit` command creates a single commit from staged changes, similar to `git commit`. Stage +your changes with `git add`, then run the action. + +Unlike `push`, the `commit` command does not require any relationship between local and remote +history. This makes it useful for broadcasting the same file changes to multiple repositories. ``` -- name: Change files - id: change-files +- name: Make and stage changes run: | echo "updating contents of bot.txt" >> bot.txt - date --rfc-3339=s >> timestamp + git add bot.txt timestamp - files="bot.txt timestamp" - - # remove an old file if it exists - # commit-headless commit will fail if you attempt to delete a file that doesn't exist on the - # remote (enforced via the GitHub API) - if [[ -f timestamp.old ]]; then - rm timestamp.old - files += " timestamp.old" - fi - - # Record the set of files we want to commit - echo "files=\"${files}\"" >> $GITHUB_OUTPUT + # Deletions work too + git rm -f old-file.txt || true - name: Create commit uses: DataDog/commit-headless@action/v%%VERSION%% @@ -89,6 +81,31 @@ single commit out of them. For that, you can use `commit-headless commit`: author: "A U Thor " # defaults to the github-actions bot account message: "a commit message" command: commit - files: "${{ steps.change-files.outputs.files }}" - force: true # default false, needs to be true to allow deletion +``` + +### Broadcasting to multiple repositories + +The `commit` command can apply the same staged changes to multiple repositories, even if they have +unrelated histories: + +``` +- name: Stage shared configuration + run: | + git add config.yml security-policy.md + +- name: Update repo1 + uses: DataDog/commit-headless@action/v%%VERSION%% + with: + target: org/repo1 + branch: main + message: "Update security policy" + command: commit + +- name: Update repo2 + uses: DataDog/commit-headless@action/v%%VERSION%% + with: + target: org/repo2 + branch: main + message: "Update security policy" + command: commit ``` diff --git a/action-template/action.js b/action-template/action.js index fd1976b..e463045 100644 --- a/action-template/action.js +++ b/action-template/action.js @@ -72,16 +72,6 @@ function main() { const message = process.env["INPUT_MESSAGE"] || ""; if(author !== "") { args.push("--author", author) } if(message !== "") { args.push("--message", message) } - - const force = process.env["INPUT_FORCE"] || "false" - if(!["true", "false"].includes(force.toLowerCase())) { - console.error(`Invalid value for force (${force}). Must be one of true or false.`); - process.exit(1); - } - - if(force.toLowerCase() === "true") { args.push("--force") } - - args.push(...process.env.INPUT_FILES.split(/\s+/)); } const child = childProcess.spawnSync(cmd, args, { diff --git a/action-template/action.yml b/action-template/action.yml index e3d09d7..4267ca3 100644 --- a/action-template/action.yml +++ b/action-template/action.yml @@ -32,11 +32,6 @@ inputs: dry-run: description: 'Stop processing just before actually making changes to the remote. Note that the pushed_ref output will be a zeroed commit hash.' default: false - files: - description: 'For commit, the list of files to include in the commit' - force: - description: 'For commit, set to true to support file deletion' - default: false author: description: 'For commit, the commit author' default: 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>' diff --git a/cmd_commit.go b/cmd_commit.go index dc797f9..02cd40c 100644 --- a/cmd_commit.go +++ b/cmd_commit.go @@ -2,10 +2,7 @@ package main import ( "context" - "errors" "fmt" - "io" - "io/fs" "os" "strings" ) @@ -13,78 +10,91 @@ import ( type CommitCmd struct { remoteFlags - Author string `help:"Specify an author using the standard 'A U Thor ' format."` - Message []string `short:"m" help:"Specify a commit message. If used multiple times, values are concatenated as separate paragraphs."` - Force bool `help:"Force commiting empty files. Only useful if you know you're deleting a file."` - Files []string `arg:"" help:"Files to commit."` + RepoPath string `name:"repo-path" default:"." help:"Path to the repository. Defaults to the current directory."` + Author string `help:"Specify an author using the standard 'A U Thor ' format."` + Message []string `short:"m" help:"Specify a commit message. If used multiple times, values are concatenated as separate paragraphs."` } func (c *CommitCmd) Help() string { return ` -This command can be used to create a single commit on the remote by passing in the names of files. +This command creates a single commit on the remote from the currently staged changes (git add). -It is expected that the paths on disk match to paths on the remote. That is, if you supply -"path/to/file.txt" then the contents of that file on disk will be applied to that same file on the -remote when the commit is created. +It works like 'git commit' - stage your changes first, then run this command to push them as a +signed commit on the remote. -You can also use this to delete files by passing a path to a file that does not exist on disk. Note -that for safety reasons, commit-headless will require an extra flag --force before accepting -deletions. It is an error to attempt to delete a file that does not exist. +The staged file paths must match the paths on the remote. That is, if you stage "path/to/file.txt" +then the contents of that file will be applied to that same path on the remote. -If you pass a path to a file that does not exist on disk without the --force flag, commit-headless -will print an error and exit. +Staged deletions (git rm) are also supported. -You can supply a commit message via --message/-m and an author via --author/-a. If unspecified, +You can supply a commit message via --message/-m and an author via --author. If unspecified, default values will be used. -Examples: - # Commit changes to these two files - commit-headless commit [flags...] -- README.md .gitlab-ci.yml +Unlike 'push', this command does not require any relationship between local and remote history. +This makes it useful for broadcasting the same file changes to multiple repositories: + + git add config.yml security-policy.md + commit-headless commit -T org/repo1 --branch main -m "Update security policy" + commit-headless commit -T org/repo2 --branch main -m "Update security policy" + commit-headless commit -T org/repo3 --branch main -m "Update security policy" - # Remove a file, add another one, and commit - rm file/i/do/not/want - echo "hello" > hi-there.txt - commit-headless commit [flags...] --force -- hi-there.txt file/i/do/not/want +Each target repository can have completely unrelated history - you're applying file contents, +not replaying commits. - # Commit a change with a custom message - commit-headless commit [flags...] -m"ran a pipeline" -- output.txt - ` +Examples: + # Stage changes and commit to remote + git add README.md .gitlab-ci.yml + commit-headless commit -T owner/repo --branch feature -m "Update docs" + + # Stage a deletion and a new file + git rm old-file.txt + git add new-file.txt + commit-headless commit -T owner/repo --branch feature -m "Replace old with new" + + # Stage all changes and commit + git add -A + commit-headless commit -T owner/repo --branch feature -m "Update everything" +` } func (c *CommitCmd) Run() error { + repo := &Repository{path: c.RepoPath} + + entries, err := repo.StagedChanges() + if err != nil { + return err + } + + if len(entries) == 0 { + log("No staged changes to commit\n") + return nil + } + change := Change{ hash: strings.Repeat("0", 40), author: c.Author, message: strings.Join(c.Message, "\n\n"), - entries: map[string][]byte{}, + entries: entries, } - rootfs := os.DirFS(".") - - for _, path := range c.Files { - path = strings.TrimPrefix(path, "./") - - fp, err := rootfs.Open(path) - if errors.Is(err, fs.ErrNotExist) { - if !c.Force { - return fmt.Errorf("file %q does not exist, but --force was not set", path) - } + ctx := context.Background() + owner, repository := c.Target.Owner(), c.Target.Repository() - change.entries[path] = nil - continue - } else if err != nil { - return fmt.Errorf("could not open file %q: %w", path, err) + // Validate --head-sha against remote HEAD (same safety check as push command) + if c.HeadSha != "" && !c.CreateBranch { + token := getToken(os.Getenv) + if token == "" { + return fmt.Errorf("no GitHub token supplied") } - - contents, err := io.ReadAll(fp) + client := NewClient(ctx, token, owner, repository, c.Branch) + remoteHead, err := client.GetHeadCommitHash(ctx) if err != nil { - return fmt.Errorf("read %q: %w", path, err) + return fmt.Errorf("get remote HEAD: %w", err) + } + if c.HeadSha != remoteHead { + return fmt.Errorf("remote HEAD %s doesn't match expected --head-sha %s (the branch may have been updated)", remoteHead, c.HeadSha) } - - change.entries[path] = contents } - owner, repository := c.Target.Owner(), c.Target.Repository() - - return pushChanges(context.Background(), owner, repository, c.Branch, c.HeadSha, c.CreateBranch, c.DryRun, change) + return pushChanges(ctx, owner, repository, c.Branch, c.HeadSha, c.CreateBranch, c.DryRun, change) } diff --git a/git.go b/git.go index 4ec7bd7..70fa356 100644 --- a/git.go +++ b/git.go @@ -190,3 +190,56 @@ func (r *Repository) fileContent(commit, path string) ([]byte, error) { cmd.Dir = r.path return cmd.Output() } + +// StagedChanges returns the files staged for commit along with their contents. +// Deleted files have nil content. Returns an empty map if there are no staged changes. +func (r *Repository) StagedChanges() (map[string][]byte, error) { + cmd := exec.Command("git", "diff", "--cached", "--name-status") + cmd.Dir = r.path + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("get staged changes: %w", err) + } + + changes := map[string][]byte{} + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + ln := scanner.Text() + if ln == "" { + continue + } + + status, path, _ := strings.Cut(ln, "\t") + switch { + case status == "A" || status == "M": + // Read content from the index (staged version) + content, err := r.stagedContent(path) + if err != nil { + return nil, fmt.Errorf("get staged content %s: %w", path, err) + } + changes[path] = content + case strings.HasPrefix(status, "R"): // Renames have the form "Rxxx\told\tnew" + from, to, _ := strings.Cut(path, "\t") + changes[from] = nil + content, err := r.stagedContent(to) + if err != nil { + return nil, fmt.Errorf("get staged content %s: %w", to, err) + } + changes[to] = content + case status == "D": + changes[path] = nil + } + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return changes, nil +} + +func (r *Repository) stagedContent(path string) ([]byte, error) { + cmd := exec.Command("git", "cat-file", "blob", ":"+path) + cmd.Dir = r.path + return cmd.Output() +} diff --git a/git_test.go b/git_test.go index 05aad6c..a3743ce 100644 --- a/git_test.go +++ b/git_test.go @@ -175,6 +175,80 @@ func TestCommitsSince(t *testing.T) { }) } +func TestStagedChanges(t *testing.T) { + tr := testRepo(t) + + // Create initial commit + requireNoError(t, os.WriteFile(tr.path("existing.txt"), []byte("original"), 0o644)) + tr.git("add", "-A") + tr.git("commit", "--message", "initial") + + r := &Repository{path: tr.root} + + t.Run("no staged changes", func(t *testing.T) { + changes, err := r.StagedChanges() + requireNoError(t, err) + if len(changes) != 0 { + t.Errorf("expected empty changes, got %d", len(changes)) + } + }) + + t.Run("staged addition", func(t *testing.T) { + requireNoError(t, os.WriteFile(tr.path("new.txt"), []byte("new content"), 0o644)) + tr.git("add", "new.txt") + + changes, err := r.StagedChanges() + requireNoError(t, err) + + if len(changes) != 1 { + t.Fatalf("expected 1 change, got %d", len(changes)) + } + if string(changes["new.txt"]) != "new content" { + t.Errorf("unexpected content: %q", changes["new.txt"]) + } + + // Cleanup + tr.git("reset", "HEAD", "new.txt") + os.Remove(tr.path("new.txt")) + }) + + t.Run("staged modification", func(t *testing.T) { + requireNoError(t, os.WriteFile(tr.path("existing.txt"), []byte("modified"), 0o644)) + tr.git("add", "existing.txt") + + changes, err := r.StagedChanges() + requireNoError(t, err) + + if len(changes) != 1 { + t.Fatalf("expected 1 change, got %d", len(changes)) + } + if string(changes["existing.txt"]) != "modified" { + t.Errorf("unexpected content: %q", changes["existing.txt"]) + } + + // Cleanup - restore file to original state + tr.git("checkout", "HEAD", "--", "existing.txt") + }) + + t.Run("staged deletion", func(t *testing.T) { + tr.git("rm", "-f", "existing.txt") + + changes, err := r.StagedChanges() + requireNoError(t, err) + + if len(changes) != 1 { + t.Fatalf("expected 1 change, got %d", len(changes)) + } + if changes["existing.txt"] != nil { + t.Errorf("expected nil for deletion, got %q", changes["existing.txt"]) + } + + // Cleanup - restore file + tr.git("reset", "HEAD", "existing.txt") + tr.git("checkout", "existing.txt") + }) +} + func TestChangedFiles(t *testing.T) { // First, prep the test repository tr := testRepo(t) From 103431ae3d9aaa4578e3433b5a9d9563dd4e73e2 Mon Sep 17 00:00:00 2001 From: Alex Vidal Date: Fri, 23 Jan 2026 12:58:45 -0600 Subject: [PATCH 05/14] feat(action): better logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, commit-headless would just print all log information to stderr and save stdout purely for the final pushed ref. It was generally fine, but we can improve it. Now, commit-headless detects when it's running in an Action context and prints out messages to standard output using proper log group workflow commands while leaving non-Action usage alone. Example: ▶ Pushing to owner/repo (branch: main) [collapsible] Commits: abc123, def456 Remote head commit: 111222 ▶ Commit abc12345: Update README [collapsible] Author: A U Thor Changed files: 1 - MODIFY: README.md ℹ️ Pushed 1 commit(s) to https://github.com/owner/repo/commits/main --- action-template/action.js | 36 ++-------- action-template/action.yml | 2 +- cmd_commit.go | 2 +- cmd_push.go | 2 +- git.go | 3 +- github.go | 38 +++++++--- github_test.go | 8 +-- logger.go | 125 ++++++++++++++++++++++++++++++++ logger_test.go | 141 +++++++++++++++++++++++++++++++++++++ main.go | 9 +-- pushchanges.go | 35 +++------ 11 files changed, 323 insertions(+), 78 deletions(-) create mode 100644 logger.go create mode 100644 logger_test.go diff --git a/action-template/action.js b/action-template/action.js index e463045..3f0a5d3 100644 --- a/action-template/action.js +++ b/action-template/action.js @@ -1,5 +1,4 @@ const childProcess = require('child_process') -const crypto = require('crypto') const fs = require('fs') const os = require('os') const process = require('process') @@ -74,41 +73,20 @@ function main() { if(message !== "") { args.push("--message", message) } } + // The Go binary handles GITHUB_OUTPUT directly and uses stdout for logs + // with workflow commands (grouping, notices, etc.) const child = childProcess.spawnSync(cmd, args, { env: env, cwd: process.env["INPUT_WORKING-DIRECTORY"] || process.cwd(), - // ignore stdin, capture stdout, stream stderr to the parent - stdio: ['ignore', 'pipe', 'inherit'], + stdio: 'inherit', }) - const exitCode = child.status - if (typeof exitCode === 'number') { - if(exitCode === 0) { - const out = child.stdout.toString().trim(); - console.log(`Pushed reference ${out}`); - - const delim = `delim_${crypto.randomUUID()}`; - fs.appendFileSync(process.env.GITHUB_OUTPUT, `pushed_ref<<${delim}${os.EOL}${out}${os.EOL}${delim}`, { encoding: "utf8" }); - process.exit(0); - } - } else { - console.error(`Child process exited uncleanly with signal ${child.signal || "unknown" }`); - if(child.error) { - console.error(` error: ${child.error}`); - } - exitCode = 128; - } - - if(child.stdout) { - // commit-headless should never print anything to stdout *except* the pushed reference, but just - // in case we'll print whatever happens here - console.log("Child process output:"); - console.log(child.stdout.toString().trim()); - console.log(); + if (child.error) { + console.error(`Failed to run commit-headless: ${child.error.message}`); + process.exit(1); } - process.exit(exitCode); - + process.exit(child.status || 0); } if (require.main === module) { diff --git a/action-template/action.yml b/action-template/action.yml index 4267ca3..58454ff 100644 --- a/action-template/action.yml +++ b/action-template/action.yml @@ -39,7 +39,7 @@ inputs: description: 'For commit, the commit message' outputs: - pushed_sha: + pushed_ref: description: 'Commit hash of the last commit created' runs: diff --git a/cmd_commit.go b/cmd_commit.go index 02cd40c..606ac82 100644 --- a/cmd_commit.go +++ b/cmd_commit.go @@ -66,7 +66,7 @@ func (c *CommitCmd) Run() error { } if len(entries) == 0 { - log("No staged changes to commit\n") + logger.Notice("No staged changes to commit") return nil } diff --git a/cmd_push.go b/cmd_push.go index 8adf72e..6a5e46d 100644 --- a/cmd_push.go +++ b/cmd_push.go @@ -74,7 +74,7 @@ func (c *PushCmd) Run() error { } if len(commits) == 0 { - log("No local commits to push (local HEAD matches remote HEAD %s)\n", baseCommit) + logger.Noticef("No local commits to push (local HEAD matches remote HEAD %s)", baseCommit) return nil } diff --git a/git.go b/git.go index 70fa356..67e2fc8 100644 --- a/git.go +++ b/git.go @@ -118,8 +118,7 @@ func (r *Repository) catfile(commit string) ([]string, string, string, error) { marker := strings.LastIndex(value, ">") if marker == -1 { // no author, or malformed, so make one up - log("Author is malformed, using a placeholder.\n") - log(" Malformed: %s\n", value) + logger.Warningf("Author is malformed (%s), using placeholder", value) author = "Commit Headless " } else { author = value[:marker+1] diff --git a/github.go b/github.go index bd82e8a..fd64d3b 100644 --- a/github.go +++ b/github.go @@ -55,14 +55,14 @@ func NewClient(ctx context.Context, token, owner, repo, branch string) *Client { } } -func (c *Client) browseCommitsURL() string { - return fmt.Sprintf("https://github.com/%s/%s/commits/%s", c.owner, c.repo, c.branch) -} - func (c *Client) commitURL(hash string) string { return fmt.Sprintf("https://github.com/%s/%s/commit/%s", c.owner, c.repo, hash) } +func (c *Client) compareURL(base, head string) string { + return fmt.Sprintf("https://github.com/%s/%s/compare/%s...%s", c.owner, c.repo, base, head) +} + // GetHeadCommitHash returns the current head commit hash for the configured repository and branch func (c *Client) GetHeadCommitHash(ctx context.Context) (string, error) { branch, resp, err := c.repos.GetBranch(ctx, c.owner, c.repo, c.branch, 0) @@ -77,7 +77,7 @@ func (c *Client) GetHeadCommitHash(ctx context.Context) (string, error) { // CreateBranch attempts to create c.branch using headSha as the branch point func (c *Client) CreateBranch(ctx context.Context, headSha string) (string, error) { - log("Creating branch from commit %s\n", headSha) + logger.Printf("Creating branch from commit %s\n", headSha) ref := github.CreateRef{ Ref: fmt.Sprintf("refs/heads/%s", c.branch), @@ -114,8 +114,31 @@ func (c *Client) PushChanges(ctx context.Context, headCommit string, changes ... // PushChange pushes a single change using the REST API. // It returns the hash of the pushed commit or an error. func (c *Client) PushChange(ctx context.Context, headCommit string, change Change) (string, error) { + shortHash := change.hash + if len(shortHash) > 8 { + shortHash = shortHash[:8] + } + endGroup := logger.Group(fmt.Sprintf("Commit %s: %s", shortHash, change.Headline())) + defer endGroup() + + // Log commit details + if change.author != "" { + logger.Printf("Author: %s\n", change.author) + } + if body := change.Body(); body != "" { + logger.Printf("Body: %s\n", body) + } + logger.Printf("Changed files: %d\n", len(change.entries)) + for path, content := range change.entries { + action := "MODIFY" + if content == nil { + action = "DELETE" + } + logger.Printf(" - %s: %s\n", action, path) + } + if c.dryrun { - log("Dry run enabled, not writing commit.\n") + logger.Notice("Dry run enabled, not writing commit") return strings.Repeat("0", len(change.hash)), nil } @@ -181,8 +204,7 @@ func (c *Client) PushChange(ctx context.Context, headCommit string, change Chang } commitSha := commit.GetSHA() - log("Pushed commit %s -> %s\n", change.hash, commitSha) - log(" Commit URL: %s\n", c.commitURL(commitSha)) + logger.Printf("Created: %s\n", c.commitURL(commitSha)) return commitSha, nil } diff --git a/github_test.go b/github_test.go index 619e52f..71ad895 100644 --- a/github_test.go +++ b/github_test.go @@ -14,7 +14,7 @@ import ( ) func init() { - logwriter = io.Discard + logger = NewLogger(io.Discard) } func TestBuildTreeEntries(t *testing.T) { @@ -714,9 +714,9 @@ func TestURLHelpers(t *testing.T) { branch: "feature-branch", } - t.Run("browseCommitsURL", func(t *testing.T) { - url := client.browseCommitsURL() - expected := "https://github.com/myorg/myrepo/commits/feature-branch" + t.Run("compareURL", func(t *testing.T) { + url := client.compareURL("abc123", "def456") + expected := "https://github.com/myorg/myrepo/compare/abc123...def456" if url != expected { t.Errorf("expected %q, got %q", expected, url) } diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..87607e6 --- /dev/null +++ b/logger.go @@ -0,0 +1,125 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "os" +) + +// Logger provides structured logging with GitHub Actions workflow command support. +type Logger struct { + w io.Writer + actions bool + githubOutput string +} + +// NewLogger creates a logger that writes to w. If running in GitHub Actions +// (detected via GITHUB_ACTIONS env var), it writes to stdout instead (required +// for workflow commands) and emits workflow commands for grouping and annotations. +func NewLogger(w io.Writer) *Logger { + actions := os.Getenv("GITHUB_ACTIONS") == "true" + githubOutput := os.Getenv("GITHUB_OUTPUT") + if actions { + w = os.Stdout + } + return &Logger{ + w: w, + actions: actions, + githubOutput: githubOutput, + } +} + +// Printf writes a formatted message to the log. +func (l *Logger) Printf(f string, args ...any) { + fmt.Fprintf(l.w, f, args...) +} + +// Group starts a collapsible group in GitHub Actions logs. +// Returns a function that must be called to end the group. +// Usage: +// +// end := logger.Group("Processing files") +// defer end() +func (l *Logger) Group(title string) func() { + if l.actions { + fmt.Fprintf(l.w, "::group::%s\n", title) + return func() { fmt.Fprintln(l.w, "::endgroup::") } + } + fmt.Fprintf(l.w, "%s\n", title) + return func() {} +} + +// Notice emits an informational annotation in GitHub Actions, +// or a regular log message otherwise. +func (l *Logger) Notice(msg string) { + if l.actions { + fmt.Fprintf(l.w, "::notice::%s\n", msg) + } else { + fmt.Fprintf(l.w, "%s\n", msg) + } +} + +// Noticef emits a formatted notice. +func (l *Logger) Noticef(f string, args ...any) { + l.Notice(fmt.Sprintf(f, args...)) +} + +// Warning emits a warning annotation in GitHub Actions, +// or a prefixed log message otherwise. +func (l *Logger) Warning(msg string) { + if l.actions { + fmt.Fprintf(l.w, "::warning::%s\n", msg) + } else { + fmt.Fprintf(l.w, "warning: %s\n", msg) + } +} + +// Warningf emits a formatted warning. +func (l *Logger) Warningf(f string, args ...any) { + l.Warning(fmt.Sprintf(f, args...)) +} + +// Error emits an error annotation in GitHub Actions, +// or a prefixed log message otherwise. +func (l *Logger) Error(msg string) { + if l.actions { + fmt.Fprintf(l.w, "::error::%s\n", msg) + } else { + fmt.Fprintf(l.w, "error: %s\n", msg) + } +} + +// Errorf emits a formatted error. +func (l *Logger) Errorf(f string, args ...any) { + l.Error(fmt.Sprintf(f, args...)) +} + +// Output writes a value that should be captured by the caller. +// In GitHub Actions, this writes to GITHUB_OUTPUT file. Otherwise, it prints to stdout. +// The name parameter is used as the output variable name in Actions. +func (l *Logger) Output(name, value string) error { + if l.githubOutput != "" { + f, err := os.OpenFile(l.githubOutput, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o644) + if err != nil { + return fmt.Errorf("open GITHUB_OUTPUT: %w", err) + } + defer f.Close() + + // Use heredoc syntax for multiline-safe output + delim := randomDelimiter() + _, err = fmt.Fprintf(f, "%s<<%s\n%s\n%s\n", name, delim, value, delim) + return err + } + + // Outside Actions, just print to stdout for capture + fmt.Println(value) + return nil +} + +func randomDelimiter() string { + b := make([]byte, 16) + rand.Read(b) + return "delim_" + hex.EncodeToString(b) +} diff --git a/logger_test.go b/logger_test.go new file mode 100644 index 0000000..913a8e3 --- /dev/null +++ b/logger_test.go @@ -0,0 +1,141 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoggerOutput(t *testing.T) { + t.Run("writes to stdout when no GITHUB_OUTPUT", func(t *testing.T) { + var buf bytes.Buffer + l := &Logger{w: &buf, actions: false, githubOutput: ""} + + err := l.Output("test_name", "test_value") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should print to stdout (which we can't capture here, but Output returns nil) + // The actual stdout write happens in the real Output method + }) + + t.Run("writes to GITHUB_OUTPUT file when set", func(t *testing.T) { + tmpDir := t.TempDir() + outputFile := filepath.Join(tmpDir, "github_output") + + var buf bytes.Buffer + l := &Logger{w: &buf, actions: true, githubOutput: outputFile} + + err := l.Output("pushed_ref", "abc123def456") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("failed to read output file: %v", err) + } + + // Should contain the variable name and value in heredoc format + if !strings.Contains(string(content), "pushed_ref< Date: Fri, 23 Jan 2026 13:16:10 -0600 Subject: [PATCH 06/14] nit: various doc updates --- .github/workflows/release.yml | 4 ---- README.md | 19 ++++++++----------- action-template/action.yml | 12 ++++++++---- github_test.go | 4 ++-- 4 files changed, 18 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e8e746..9ed61d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,10 +74,6 @@ jobs: --message="action: ${subject}" \ --allow-empty # sometimes we have nothing to change, so this ensures we can still commit - REF=$(git rev-parse HEAD) - echo "sha=${REF}" >> $GITHUB_OUTPUT - echo "Created commit ${REF}" - - name: push commits id: push-commits uses: ./ # use the action defined in the action branch diff --git a/README.md b/README.md index dff59e0..26bb31b 100644 --- a/README.md +++ b/README.md @@ -3,20 +3,17 @@ A binary tool and GitHub Action for creating signed commits from headless workflows For the Action, please see [the action branch][action-branch] and the associated `action/` -release tags. For example usage, see [Examples](#examples). +release tags. `commit-headless` is focused on turning local commits into signed commits on the remote. It does -this using the GitHub API, more specifically the [createCommitOnBranch][mutation] mutation. When -commits are created using the API (instead of via `git push`), the commits will be signed and -verified by GitHub on behalf of the owner of the credentials used to access the API. +this using the GitHub REST API to create commits. When commits are created using the API (instead +of via `git push`), the commits will be signed and verified by GitHub on behalf of the owner of the +credentials used to access the API. +*NOTE:* One limitation of creating commits using the API is that it does not expose any mechanism +to set or change file modes. This means that if you rely on `commit-headless` to push binary files +(or executable scripts), the file in the resulting commit will not retain the executable bit. -*NOTE:* One limitation of creating commits using the GraphQL API is that it does not expose any -mechanism to set or change file modes. It merely takes the file contents, base64 encoded. This means -that if you rely on `commit-headless` to push binary files (or executable scripts), the file in the -resulting commit will not retain that executable bit. - -[mutation]: https://docs.github.com/en/graphql/reference/mutations#createcommitonbranch [action-branch]: https://github.com/DataDog/commit-headless/tree/action ## Usage @@ -63,7 +60,7 @@ and supply a commit hash to use as a branch point via `--head-sha`. With this fl `commit-headless` will create the branch on GitHub from that commit hash if it doesn't already exist. -Example: `commit-headless [flags...] --head-sha=$(git rev-parse main HEAD) --create-branch ...` +Example: `commit-headless [flags...] --head-sha=$(git rev-parse HEAD) --create-branch ...` ### commit-headless push diff --git a/action-template/action.yml b/action-template/action.yml index 58454ff..5042139 100644 --- a/action-template/action.yml +++ b/action-template/action.yml @@ -1,12 +1,16 @@ -name: Create signed commits out of local commits or a set of changed files. +name: Create signed commits from local commits or staged changes. description: | - This GitHub Action was built specifically to simplify creating signed and verified commits on GitHub. + This GitHub Action simplifies creating signed and verified commits on GitHub. The created commits will be signed, and committer and author attribution will be the owner of the token that was used to create the commit. This is part of the GitHub API and cannot be changed. - However, the original commit author and message will be retained as a "Co-authored-by" trailer and - the message body, respectively. + + For the 'push' command, the original commit author and message will be retained as a + "Co-authored-by" trailer and the message body, respectively. + + For the 'commit' command, use --author and --message to specify commit metadata. This command + reads staged changes (git add) and can be used to broadcast changes to multiple repositories. inputs: token: description: 'GitHub token' diff --git a/github_test.go b/github_test.go index 71ad895..a086c17 100644 --- a/github_test.go +++ b/github_test.go @@ -328,8 +328,8 @@ func TestPushChange(t *testing.T) { case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/trees": var req struct { - BaseTree string `json:"base_tree"` - Tree []*github.TreeEntry `json:"tree"` + BaseTree string `json:"base_tree"` + Tree []*github.TreeEntry `json:"tree"` } json.NewDecoder(r.Body).Decode(&req) treeEntries = req.Tree From 7404852582b78522b29cb3d59df1edd0b4dad004 Mon Sep 17 00:00:00 2001 From: Alex Vidal Date: Fri, 23 Jan 2026 13:24:15 -0600 Subject: [PATCH 07/14] feat: preserve file modes when creating remote commits --- README.md | 4 +-- change.go | 11 +++++-- git.go | 86 ++++++++++++++++++++++++++++++++++---------------- git_test.go | 42 ++++++++++++++++++------ github.go | 18 +++++++---- github_test.go | 53 +++++++++++++++++-------------- 6 files changed, 141 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 26bb31b..f95c020 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,7 @@ this using the GitHub REST API to create commits. When commits are created using of via `git push`), the commits will be signed and verified by GitHub on behalf of the owner of the credentials used to access the API. -*NOTE:* One limitation of creating commits using the API is that it does not expose any mechanism -to set or change file modes. This means that if you rely on `commit-headless` to push binary files -(or executable scripts), the file in the resulting commit will not retain the executable bit. +File modes (such as the executable bit) are preserved when pushing commits. [action-branch]: https://github.com/DataDog/commit-headless/tree/action diff --git a/change.go b/change.go index b0efff2..9954ff4 100644 --- a/change.go +++ b/change.go @@ -5,6 +5,12 @@ import ( "strings" ) +// FileEntry represents a file in a change with its content and mode. +type FileEntry struct { + Content []byte // nil indicates deletion, empty slice indicates empty file + Mode string // git file mode (e.g., "100644", "100755") +} + // Change represents a single change that will be pushed to the remote. type Change struct { hash string @@ -15,9 +21,8 @@ type Change struct { // trailers are lines to add to the end of the body stored as a list to maintain insertion order trailers []string - // entries is a map of path -> content for files modified in the change - // empty or nil content indicates a deleted file - entries map[string][]byte + // entries is a map of path -> FileEntry for files modified in the change + entries map[string]FileEntry } // Splits a commit message on the first blank line diff --git a/git.go b/git.go index 67e2fc8..557ee19 100644 --- a/git.go +++ b/git.go @@ -76,7 +76,7 @@ func (r *Repository) changed(commit string) (Change, error) { hash: commit, message: message, author: author, - entries: map[string][]byte{}, + entries: map[string]FileEntry{}, } change.entries, err = r.changedFiles(commit) @@ -141,9 +141,9 @@ func (r *Repository) catfile(commit string) ([]string, string, string, error) { return parents, author, message, nil } -// Returns the files changed in the given commit, along with their contents -// Deleted files will have an empty value -func (r *Repository) changedFiles(commit string) (map[string][]byte, error) { +// Returns the files changed in the given commit, along with their contents and modes. +// Deleted files will have nil content. +func (r *Repository) changedFiles(commit string) (map[string]FileEntry, error) { cmd := exec.Command("git", "diff-tree", "--no-commit-id", "--name-status", "-r", commit) cmd.Dir = r.path out, err := cmd.Output() @@ -151,7 +151,7 @@ func (r *Repository) changedFiles(commit string) (map[string][]byte, error) { return nil, err } - changes := map[string][]byte{} + changes := map[string]FileEntry{} scanner := bufio.NewScanner(bytes.NewReader(out)) for scanner.Scan() { ln := scanner.Text() @@ -159,21 +159,21 @@ func (r *Repository) changedFiles(commit string) (map[string][]byte, error) { status, value, _ := strings.Cut(ln, "\t") switch { case status == "A" || status == "M": - contents, err := r.fileContent(commit, value) + content, mode, err := r.fileContentAndMode(commit, value) if err != nil { return nil, fmt.Errorf("get content %s:%s: %w", commit, value, err) } - changes[value] = contents + changes[value] = FileEntry{Content: content, Mode: mode} case strings.HasPrefix(status, "R"): // Renames may have a similarity score after the R from, to, _ := strings.Cut(value, "\t") - changes[from] = nil - contents, err := r.fileContent(commit, to) + changes[from] = FileEntry{Content: nil, Mode: ""} + content, mode, err := r.fileContentAndMode(commit, to) if err != nil { return nil, fmt.Errorf("get content %s:%s: %w", commit, to, err) } - changes[to] = contents + changes[to] = FileEntry{Content: content, Mode: mode} case status == "D": - changes[value] = nil + changes[value] = FileEntry{Content: nil, Mode: ""} } } @@ -184,15 +184,32 @@ func (r *Repository) changedFiles(commit string) (map[string][]byte, error) { return changes, nil } -func (r *Repository) fileContent(commit, path string) ([]byte, error) { - cmd := exec.Command("git", "cat-file", "blob", fmt.Sprintf("%s:%s", commit, path)) +func (r *Repository) fileContentAndMode(commit, path string) ([]byte, string, error) { + // Get the file mode from ls-tree + cmd := exec.Command("git", "ls-tree", commit, "--", path) cmd.Dir = r.path - return cmd.Output() + out, err := cmd.Output() + if err != nil { + return nil, "", fmt.Errorf("ls-tree: %w", err) + } + + // Output format: mode SP type SP hash TAB path + mode := strings.SplitN(string(out), " ", 2)[0] + + // Get the file content + cmd = exec.Command("git", "cat-file", "blob", fmt.Sprintf("%s:%s", commit, path)) + cmd.Dir = r.path + content, err := cmd.Output() + if err != nil { + return nil, "", fmt.Errorf("cat-file: %w", err) + } + + return content, mode, nil } -// StagedChanges returns the files staged for commit along with their contents. +// StagedChanges returns the files staged for commit along with their contents and modes. // Deleted files have nil content. Returns an empty map if there are no staged changes. -func (r *Repository) StagedChanges() (map[string][]byte, error) { +func (r *Repository) StagedChanges() (map[string]FileEntry, error) { cmd := exec.Command("git", "diff", "--cached", "--name-status") cmd.Dir = r.path out, err := cmd.Output() @@ -200,7 +217,7 @@ func (r *Repository) StagedChanges() (map[string][]byte, error) { return nil, fmt.Errorf("get staged changes: %w", err) } - changes := map[string][]byte{} + changes := map[string]FileEntry{} scanner := bufio.NewScanner(bytes.NewReader(out)) for scanner.Scan() { ln := scanner.Text() @@ -211,22 +228,21 @@ func (r *Repository) StagedChanges() (map[string][]byte, error) { status, path, _ := strings.Cut(ln, "\t") switch { case status == "A" || status == "M": - // Read content from the index (staged version) - content, err := r.stagedContent(path) + content, mode, err := r.stagedContentAndMode(path) if err != nil { return nil, fmt.Errorf("get staged content %s: %w", path, err) } - changes[path] = content + changes[path] = FileEntry{Content: content, Mode: mode} case strings.HasPrefix(status, "R"): // Renames have the form "Rxxx\told\tnew" from, to, _ := strings.Cut(path, "\t") - changes[from] = nil - content, err := r.stagedContent(to) + changes[from] = FileEntry{Content: nil, Mode: ""} + content, mode, err := r.stagedContentAndMode(to) if err != nil { return nil, fmt.Errorf("get staged content %s: %w", to, err) } - changes[to] = content + changes[to] = FileEntry{Content: content, Mode: mode} case status == "D": - changes[path] = nil + changes[path] = FileEntry{Content: nil, Mode: ""} } } @@ -237,8 +253,24 @@ func (r *Repository) StagedChanges() (map[string][]byte, error) { return changes, nil } -func (r *Repository) stagedContent(path string) ([]byte, error) { - cmd := exec.Command("git", "cat-file", "blob", ":"+path) +func (r *Repository) stagedContentAndMode(path string) ([]byte, string, error) { + // Get mode from ls-files -s (format: mode SP hash SP stage TAB path) + cmd := exec.Command("git", "ls-files", "-s", "--", path) cmd.Dir = r.path - return cmd.Output() + out, err := cmd.Output() + if err != nil { + return nil, "", fmt.Errorf("ls-files: %w", err) + } + + mode := strings.SplitN(string(out), " ", 2)[0] + + // Get content from the index + cmd = exec.Command("git", "cat-file", "blob", ":"+path) + cmd.Dir = r.path + content, err := cmd.Output() + if err != nil { + return nil, "", fmt.Errorf("cat-file: %w", err) + } + + return content, mode, nil } diff --git a/git_test.go b/git_test.go index a3743ce..9738bef 100644 --- a/git_test.go +++ b/git_test.go @@ -203,8 +203,11 @@ func TestStagedChanges(t *testing.T) { if len(changes) != 1 { t.Fatalf("expected 1 change, got %d", len(changes)) } - if string(changes["new.txt"]) != "new content" { - t.Errorf("unexpected content: %q", changes["new.txt"]) + if string(changes["new.txt"].Content) != "new content" { + t.Errorf("unexpected content: %q", changes["new.txt"].Content) + } + if changes["new.txt"].Mode != "100644" { + t.Errorf("unexpected mode: %q", changes["new.txt"].Mode) } // Cleanup @@ -212,6 +215,25 @@ func TestStagedChanges(t *testing.T) { os.Remove(tr.path("new.txt")) }) + t.Run("staged executable", func(t *testing.T) { + requireNoError(t, os.WriteFile(tr.path("script.sh"), []byte("#!/bin/bash\necho hello"), 0o755)) + tr.git("add", "script.sh") + + changes, err := r.StagedChanges() + requireNoError(t, err) + + if len(changes) != 1 { + t.Fatalf("expected 1 change, got %d", len(changes)) + } + if changes["script.sh"].Mode != "100755" { + t.Errorf("expected executable mode 100755, got %q", changes["script.sh"].Mode) + } + + // Cleanup + tr.git("reset", "HEAD", "script.sh") + os.Remove(tr.path("script.sh")) + }) + t.Run("staged modification", func(t *testing.T) { requireNoError(t, os.WriteFile(tr.path("existing.txt"), []byte("modified"), 0o644)) tr.git("add", "existing.txt") @@ -222,8 +244,8 @@ func TestStagedChanges(t *testing.T) { if len(changes) != 1 { t.Fatalf("expected 1 change, got %d", len(changes)) } - if string(changes["existing.txt"]) != "modified" { - t.Errorf("unexpected content: %q", changes["existing.txt"]) + if string(changes["existing.txt"].Content) != "modified" { + t.Errorf("unexpected content: %q", changes["existing.txt"].Content) } // Cleanup - restore file to original state @@ -239,8 +261,8 @@ func TestStagedChanges(t *testing.T) { if len(changes) != 1 { t.Fatalf("expected 1 change, got %d", len(changes)) } - if changes["existing.txt"] != nil { - t.Errorf("expected nil for deletion, got %q", changes["existing.txt"]) + if changes["existing.txt"].Content != nil { + t.Errorf("expected nil for deletion, got %q", changes["existing.txt"].Content) } // Cleanup - restore file @@ -287,13 +309,13 @@ func TestChangedFiles(t *testing.T) { t.Fatalf("expected changed files to be 'to-delete' and 'to-empty', got %q", keys) } - if change.entries["to-empty"] == nil { - t.Log("expected to-empty to be empty, not nil") + if change.entries["to-empty"].Content == nil { + t.Log("expected to-empty to have empty content, not nil") t.Fail() } - if change.entries["to-delete"] != nil { - t.Logf("expected to-delete to be nil, got %q", change.entries["to-delete"]) + if change.entries["to-delete"].Content != nil { + t.Logf("expected to-delete to have nil content, got %q", change.entries["to-delete"].Content) t.Fail() } } diff --git a/github.go b/github.go index fd64d3b..9b1cd19 100644 --- a/github.go +++ b/github.go @@ -129,9 +129,9 @@ func (c *Client) PushChange(ctx context.Context, headCommit string, change Chang logger.Printf("Body: %s\n", body) } logger.Printf("Changed files: %d\n", len(change.entries)) - for path, content := range change.entries { + for path, fe := range change.entries { action := "MODIFY" - if content == nil { + if fe.Content == nil { action = "DELETE" } logger.Printf(" - %s: %s\n", action, path) @@ -151,18 +151,24 @@ func (c *Client) PushChange(ctx context.Context, headCommit string, change Chang // Build tree entries var entries []*github.TreeEntry - for path, content := range change.entries { + for path, fe := range change.entries { + // Use the file's mode, defaulting to 100644 for regular files + mode := fe.Mode + if mode == "" { + mode = "100644" + } + entry := &github.TreeEntry{ Path: github.Ptr(path), - Mode: github.Ptr("100644"), + Mode: github.Ptr(mode), Type: github.Ptr("blob"), } - if content == nil { + if fe.Content == nil { // Deletion: SHA must be empty string for go-github to omit it } else { // Create blob for additions/modifications blob, _, err := c.git.CreateBlob(ctx, c.owner, c.repo, github.Blob{ - Content: github.Ptr(string(content)), + Content: github.Ptr(string(fe.Content)), Encoding: github.Ptr("utf-8"), }) if err != nil { diff --git a/github_test.go b/github_test.go index a086c17..9dd00cf 100644 --- a/github_test.go +++ b/github_test.go @@ -17,19 +17,24 @@ func init() { logger = NewLogger(io.Discard) } +// newFileEntry creates a FileEntry with default mode 100644 +func newFileEntry(content []byte) FileEntry { + return FileEntry{Content: content, Mode: "100644"} +} + func TestBuildTreeEntries(t *testing.T) { change := Change{ - entries: map[string][]byte{ - "a-file": []byte("hello world"), - "b-empty": {}, - "deleted": nil, + entries: map[string]FileEntry{ + "a-file": newFileEntry([]byte("hello world")), + "b-empty": newFileEntry([]byte{}), + "deleted": {Content: nil}, // deletion }, } // Build tree entries the same way PushChange does (without making API calls) var addedPaths, deletedPaths []string - for path, content := range change.entries { - if content == nil { + for path, fe := range change.entries { + if fe.Content == nil { deletedPaths = append(deletedPaths, path) } else { addedPaths = append(addedPaths, path) @@ -197,8 +202,8 @@ func TestPushChange(t *testing.T) { change := Change{ hash: "abc123", message: "Test commit", - entries: map[string][]byte{ - "file.txt": []byte("content"), + entries: map[string]FileEntry{ + "file.txt": newFileEntry([]byte("content")), }, } @@ -281,8 +286,8 @@ func TestPushChange(t *testing.T) { change := Change{ hash: "local-hash", message: "Test commit", - entries: map[string][]byte{ - "file.txt": []byte("content"), + entries: map[string]FileEntry{ + "file.txt": newFileEntry([]byte("content")), }, } @@ -362,8 +367,8 @@ func TestPushChange(t *testing.T) { change := Change{ hash: "local-hash", message: "Delete file", - entries: map[string][]byte{ - "deleted-file.txt": nil, // nil means deletion + entries: map[string]FileEntry{ + "deleted-file.txt": {Content: nil}, // deletion }, } @@ -424,7 +429,7 @@ func TestPushChange(t *testing.T) { change := Change{ hash: "local", message: "Headline\n\nThis is the body\nwith multiple lines", - entries: map[string][]byte{"file.txt": []byte("x")}, + entries: map[string]FileEntry{"file.txt": newFileEntry([]byte("x"))}, } _, err := client.PushChange(context.Background(), "parent", change) @@ -450,7 +455,7 @@ func TestPushChange(t *testing.T) { change := Change{ hash: "local", message: "Test", - entries: map[string][]byte{"file.txt": []byte("x")}, + entries: map[string]FileEntry{"file.txt": newFileEntry([]byte("x"))}, } _, err := client.PushChange(context.Background(), "nonexistent", change) @@ -483,7 +488,7 @@ func TestPushChange(t *testing.T) { change := Change{ hash: "local", message: "Test", - entries: map[string][]byte{"file.txt": []byte("x")}, + entries: map[string]FileEntry{"file.txt": newFileEntry([]byte("x"))}, } _, err := client.PushChange(context.Background(), "parent", change) @@ -517,7 +522,7 @@ func TestPushChange(t *testing.T) { change := Change{ hash: "local", message: "Test", - entries: map[string][]byte{"file.txt": []byte("x")}, + entries: map[string]FileEntry{"file.txt": newFileEntry([]byte("x"))}, } _, err := client.PushChange(context.Background(), "parent", change) @@ -554,7 +559,7 @@ func TestPushChange(t *testing.T) { change := Change{ hash: "local", message: "Test", - entries: map[string][]byte{"file.txt": []byte("x")}, + entries: map[string]FileEntry{"file.txt": newFileEntry([]byte("x"))}, } _, err := client.PushChange(context.Background(), "parent", change) @@ -594,7 +599,7 @@ func TestPushChange(t *testing.T) { change := Change{ hash: "local", message: "Test", - entries: map[string][]byte{"file.txt": []byte("x")}, + entries: map[string]FileEntry{"file.txt": newFileEntry([]byte("x"))}, } _, err := client.PushChange(context.Background(), "parent", change) @@ -638,9 +643,9 @@ func TestPushChanges(t *testing.T) { client := newTestClient(t, server) changes := []Change{ - {hash: "h1", message: "First", entries: map[string][]byte{"a.txt": []byte("a")}}, - {hash: "h2", message: "Second", entries: map[string][]byte{"b.txt": []byte("b")}}, - {hash: "h3", message: "Third", entries: map[string][]byte{"c.txt": []byte("c")}}, + {hash: "h1", message: "First", entries: map[string]FileEntry{"a.txt": newFileEntry([]byte("a"))}}, + {hash: "h2", message: "Second", entries: map[string]FileEntry{"b.txt": newFileEntry([]byte("b"))}}, + {hash: "h3", message: "Third", entries: map[string]FileEntry{"c.txt": newFileEntry([]byte("c"))}}, } count, sha, err := client.PushChanges(context.Background(), "initial", changes...) @@ -692,9 +697,9 @@ func TestPushChanges(t *testing.T) { client := newTestClient(t, server) changes := []Change{ - {hash: "h1", message: "First", entries: map[string][]byte{"a.txt": []byte("a")}}, - {hash: "h2", message: "Second", entries: map[string][]byte{"b.txt": []byte("b")}}, - {hash: "h3", message: "Third", entries: map[string][]byte{"c.txt": []byte("c")}}, + {hash: "h1", message: "First", entries: map[string]FileEntry{"a.txt": newFileEntry([]byte("a"))}}, + {hash: "h2", message: "Second", entries: map[string]FileEntry{"b.txt": newFileEntry([]byte("b"))}}, + {hash: "h3", message: "Third", entries: map[string]FileEntry{"c.txt": newFileEntry([]byte("c"))}}, } count, _, err := client.PushChanges(context.Background(), "initial", changes...) From e2cfb934ff953b4651dc5f9f3fc94b9b0a3a3f21 Mon Sep 17 00:00:00 2001 From: Alex Vidal Date: Fri, 23 Jan 2026 13:46:10 -0600 Subject: [PATCH 08/14] feat: add an action based test suite --- .github/workflows/release.yml | 60 +++++++++-- .github/workflows/test.yml | 194 ++++++++++++++++++++++++++++++++++ action-template/action.js | 7 ++ action-template/action.yml | 3 + 4 files changed, 257 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ed61d3..884782c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,13 +10,11 @@ on: - 'main' jobs: - release: + build: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - fetch-depth: 0 # needed to make sure we get all tags - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version: '1.24' @@ -25,13 +23,61 @@ jobs: - name: build run: | + mkdir -p dist GOOS=linux GOARCH=amd64 go build -buildvcs=false -o ./dist/commit-headless-linux-amd64 . GOOS=linux GOARCH=arm64 go build -buildvcs=false -o ./dist/commit-headless-linux-arm64 . - # TODO: Not sure how to determine the current os/arch to select one of the above binaries - # so we're just going to build another one - go build -buildvcs=false -o ./dist/commit-headless . - ./dist/commit-headless version | awk '{print $3}' > ./dist/VERSION.txt + - name: upload artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: binaries + path: dist/ + + verify-binaries: + needs: build + runs-on: ${{ matrix.runner }} + + strategy: + matrix: + include: + - runner: ubuntu-latest + binary: commit-headless-linux-amd64 + - runner: ubuntu-24.04-arm + binary: commit-headless-linux-arm64 + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: download artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: binaries + path: action-template/dist/ + + - name: verify binary + uses: ./action-template + with: + print-version: true + + release: + needs: verify-binaries + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 # needed to make sure we get all tags + + - name: download artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: binaries + path: dist/ + + - name: get version + run: | + chmod +x ./dist/commit-headless-linux-amd64 + ./dist/commit-headless-linux-amd64 version | awk '{print $3}' > ./dist/VERSION.txt echo "Current version: $(cat ./dist/VERSION.txt)" - name: create action branch commit diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..43f41d6 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,194 @@ +# Integration tests for commit-headless action +name: Test + +permissions: + contents: write + +on: + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + unit-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version: '1.24' + + - name: Run Go tests + run: go test -v ./... + + integration-tests: + runs-on: ubuntu-latest + needs: unit-tests + + env: + TEST_BRANCH: test/ci-${{ github.run_id }} + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version: '1.24' + + - name: Build action + run: | + mkdir -p ./action-template/dist + go build -buildvcs=false -o ./action-template/dist/commit-headless-linux-amd64 . + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # Test: push command with multiple commits + - name: "Test push: create local commits" + run: | + echo "First change" > test-file.txt + git add test-file.txt + git commit -m "test: first commit" + + echo "Second change" >> test-file.txt + git add test-file.txt + git commit -m "test: second commit" + + - name: "Test push: push commits to new branch" + id: test-push + uses: ./action-template + with: + branch: ${{ env.TEST_BRANCH }} + head-sha: ${{ github.sha }} + create-branch: true + command: push + + - name: "Test push: verify output" + run: | + if [ -z "${{ steps.test-push.outputs.pushed_ref }}" ]; then + echo "ERROR: pushed_ref output is empty" + exit 1 + fi + echo "Pushed ref: ${{ steps.test-push.outputs.pushed_ref }}" + + - name: "Test push: verify commits on remote" + run: | + git fetch origin ${{ env.TEST_BRANCH }} + + # Check that both commits exist + log=$(git log origin/${{ env.TEST_BRANCH }} --oneline -3) + echo "Remote log:" + echo "$log" + + if ! echo "$log" | grep -q "test: first commit"; then + echo "ERROR: first commit not found on remote" + exit 1 + fi + if ! echo "$log" | grep -q "test: second commit"; then + echo "ERROR: second commit not found on remote" + exit 1 + fi + + # Test: push with no changes (should succeed with exit 0) + # After API push, remote commits have different hashes than local commits, + # so we need to sync local with remote first. + - name: "Test push: sync local with remote" + run: | + git fetch origin ${{ env.TEST_BRANCH }} + git reset --hard origin/${{ env.TEST_BRANCH }} + + - name: "Test push: no changes succeeds" + uses: ./action-template + with: + branch: ${{ env.TEST_BRANCH }} + command: push + + # Test: commit command with staged changes + - name: "Test commit: stage changes" + run: | + echo "Committed via commit command" > commit-test.txt + git add commit-test.txt + + - name: "Test commit: push staged changes" + id: test-commit + uses: ./action-template + with: + branch: ${{ env.TEST_BRANCH }} + message: "test: commit command" + command: commit + + - name: "Test commit: verify output" + run: | + if [ -z "${{ steps.test-commit.outputs.pushed_ref }}" ]; then + echo "ERROR: pushed_ref output is empty" + exit 1 + fi + echo "Pushed ref: ${{ steps.test-commit.outputs.pushed_ref }}" + + - name: "Test commit: verify on remote" + run: | + git fetch origin ${{ env.TEST_BRANCH }} + + log=$(git log origin/${{ env.TEST_BRANCH }} --oneline -1) + echo "Remote log:" + echo "$log" + + if ! echo "$log" | grep -q "test: commit command"; then + echo "ERROR: commit not found on remote" + exit 1 + fi + + # Test: commit with no staged changes (should succeed with exit 0) + - name: "Test commit: no staged changes succeeds" + uses: ./action-template + with: + branch: ${{ env.TEST_BRANCH }} + message: "this should not appear" + command: commit + + # Test: file mode preservation + # Sync local with remote after commit command created new remote commits + - name: "Test modes: sync local with remote" + run: | + git fetch origin ${{ env.TEST_BRANCH }} + git reset --hard origin/${{ env.TEST_BRANCH }} + + - name: "Test modes: create executable" + run: | + echo '#!/bin/bash' > script.sh + echo 'echo "Hello from script"' >> script.sh + chmod +x script.sh + git add script.sh + git commit -m "test: add executable script" + + - name: "Test modes: push executable" + uses: ./action-template + with: + branch: ${{ env.TEST_BRANCH }} + command: push + + - name: "Test modes: verify executable bit preserved" + run: | + git fetch origin ${{ env.TEST_BRANCH }} + + # Get the file mode from the remote + mode=$(git ls-tree origin/${{ env.TEST_BRANCH }} -- script.sh | awk '{print $1}') + echo "File mode on remote: $mode" + + if [ "$mode" != "100755" ]; then + echo "ERROR: executable bit not preserved, expected 100755 got $mode" + exit 1 + fi + echo "Executable bit preserved successfully" + + # Cleanup: delete test branch + - name: Cleanup test branch + if: always() + run: | + git push origin --delete ${{ env.TEST_BRANCH }} || true diff --git a/action-template/action.js b/action-template/action.js index 3f0a5d3..f4b9313 100644 --- a/action-template/action.js +++ b/action-template/action.js @@ -29,6 +29,13 @@ function main() { console.error(`Error making binary executable: ${err.message}`); } + // Handle print-version: just run version command and exit + const printVersion = (process.env["INPUT_PRINT-VERSION"] || "false").toLowerCase(); + if (printVersion === "true") { + const child = childProcess.spawnSync(cmd, ["version"], { stdio: 'inherit' }); + process.exit(child.status || 0); + } + const env = { ...process.env }; env.HEADLESS_TOKEN = process.env.INPUT_TOKEN; diff --git a/action-template/action.yml b/action-template/action.yml index 5042139..0d362c1 100644 --- a/action-template/action.yml +++ b/action-template/action.yml @@ -41,6 +41,9 @@ inputs: default: 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>' message: description: 'For commit, the commit message' + print-version: + # Undocumented: prints version and exits. Used by release workflow. + default: false outputs: pushed_ref: From bc52ba611b8fb8e57eca83059620f52a5cb2bc57 Mon Sep 17 00:00:00 2001 From: Alex Vidal Date: Fri, 23 Jan 2026 14:32:22 -0600 Subject: [PATCH 09/14] feat: add new replay command This patch adds commit-headless replay, useful for situations where you want to replace a series of existing remote commits with signed equivalents. See README.md or `commit-headless reply --help` for more. --- .github/workflows/test.yml | 129 ++++++++++++++++++++++++++++++++++ CHANGELOG.md | 42 +++++++++++ README.md | 22 +++++- action-template/README.md | 24 +++++++ action-template/action.js | 13 +++- action-template/action.yml | 4 +- cmd_replay.go | 139 +++++++++++++++++++++++++++++++++++++ git.go | 49 +++++++++++++ git_test.go | 73 +++++++++++++++++++ github.go | 3 +- main.go | 1 + 11 files changed, 494 insertions(+), 5 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 cmd_replay.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 43f41d6..a1f7f19 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -187,6 +187,135 @@ jobs: fi echo "Executable bit preserved successfully" + # Test: replay command (re-sign existing commits) + - name: "Test replay: sync and record base" + id: replay-setup + run: | + git fetch origin ${{ env.TEST_BRANCH }} + git reset --hard origin/${{ env.TEST_BRANCH }} + + # Record the current HEAD as the base for replay + echo "base_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + + - name: "Test replay: create commits with regular git push" + run: | + # Create commits that will be "unsigned" (pushed via git, not API) + echo "replay test 1" > replay-test.txt + git add replay-test.txt + git commit -m "test: replay commit 1" + + echo "replay test 2" >> replay-test.txt + git add replay-test.txt + git commit -m "test: replay commit 2" + + # Push directly with git (these won't be signed) + git push origin HEAD:${{ env.TEST_BRANCH }} + + - name: "Test replay: record pre-replay HEAD" + id: pre-replay + run: | + git fetch origin ${{ env.TEST_BRANCH }} + echo "head_sha=$(git rev-parse origin/${{ env.TEST_BRANCH }})" >> $GITHUB_OUTPUT + + - name: "Test replay: replay commits as signed" + id: test-replay + uses: ./action-template + with: + branch: ${{ env.TEST_BRANCH }} + since: ${{ steps.replay-setup.outputs.base_sha }} + command: replay + + - name: "Test replay: verify output" + run: | + if [ -z "${{ steps.test-replay.outputs.pushed_ref }}" ]; then + echo "ERROR: pushed_ref output is empty" + exit 1 + fi + echo "Pushed ref: ${{ steps.test-replay.outputs.pushed_ref }}" + + - name: "Test replay: verify commits were replayed" + run: | + git fetch origin ${{ env.TEST_BRANCH }} + + # The HEAD should be different (new signed commits) + new_head=$(git rev-parse origin/${{ env.TEST_BRANCH }}) + old_head="${{ steps.pre-replay.outputs.head_sha }}" + + echo "Old HEAD: $old_head" + echo "New HEAD: $new_head" + + if [ "$new_head" = "$old_head" ]; then + echo "ERROR: HEAD unchanged after replay" + exit 1 + fi + + # But the commit messages should still be there + log=$(git log origin/${{ env.TEST_BRANCH }} --oneline -3) + echo "Remote log:" + echo "$log" + + if ! echo "$log" | grep -q "test: replay commit 1"; then + echo "ERROR: replay commit 1 not found" + exit 1 + fi + if ! echo "$log" | grep -q "test: replay commit 2"; then + echo "ERROR: replay commit 2 not found" + exit 1 + fi + + echo "Replay test passed: commits were replayed with new hashes" + + # Test: replay single commit (common case: one action-generated unsigned commit) + - name: "Test replay single: sync and record base" + id: replay-single-setup + run: | + git fetch origin ${{ env.TEST_BRANCH }} + git reset --hard origin/${{ env.TEST_BRANCH }} + echo "base_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + + - name: "Test replay single: create one commit with regular git push" + run: | + echo "single replay test" > single-replay.txt + git add single-replay.txt + git commit -m "test: single unsigned commit" + git push origin HEAD:${{ env.TEST_BRANCH }} + + - name: "Test replay single: record pre-replay HEAD" + id: pre-replay-single + run: | + git fetch origin ${{ env.TEST_BRANCH }} + echo "head_sha=$(git rev-parse origin/${{ env.TEST_BRANCH }})" >> $GITHUB_OUTPUT + + - name: "Test replay single: replay single commit as signed" + id: test-replay-single + uses: ./action-template + with: + branch: ${{ env.TEST_BRANCH }} + since: ${{ steps.replay-single-setup.outputs.base_sha }} + command: replay + + - name: "Test replay single: verify" + run: | + git fetch origin ${{ env.TEST_BRANCH }} + + new_head=$(git rev-parse origin/${{ env.TEST_BRANCH }}) + old_head="${{ steps.pre-replay-single.outputs.head_sha }}" + + if [ "$new_head" = "$old_head" ]; then + echo "ERROR: HEAD unchanged after replay" + exit 1 + fi + + log=$(git log origin/${{ env.TEST_BRANCH }} --oneline -1) + echo "Remote log: $log" + + if ! echo "$log" | grep -q "test: single unsigned commit"; then + echo "ERROR: single commit not found" + exit 1 + fi + + echo "Single commit replay test passed" + # Cleanup: delete test branch - name: Cleanup test branch if: always() diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..684b59d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +## v3.0.0 + +### Breaking Changes + +- **push command no longer accepts commit arguments**: The push command now automatically + determines which local commits need to be pushed by comparing local HEAD with the remote branch + HEAD. Previously, you could specify which commits to push as arguments. If the remote HEAD is not + an ancestor of local HEAD, the push will fail due to diverged history. + +- **commit command no longer accepts file arguments**: The commit command now reads from staged + changes (via `git add`), similar to how `git commit` works. Previously, you had to specify the + list of files to include in the commit. Stage your changes first, then run the command. + +- **Action inputs removed**: + - `commits` input removed from push (commits are now auto-detected) + - `files` input removed from commit (files are now read from staging area) + +### Features + +- **New `replay` command**: Replays existing remote commits as signed commits. Useful when a bot or + action creates unsigned commits that you want to replace with signed versions. The command + fetches the remote, extracts commits since a specified base, recreates them as signed, and + force-updates the branch. + +- **File mode preservation**: Executable bits and other file modes are now preserved when pushing + commits. Previously all files were created with mode `100644`. + +- **GitHub Actions logging**: When running in GitHub Actions, output now uses workflow commands for + better integration: + - Commit operations are grouped for cleaner logs + - Success/failure notices appear in the workflow summary + - Warnings and errors use appropriate annotation levels + +- **REST API**: Switched from GraphQL API to REST API internally, enabling file mode support and + improved error handling. + +### Other Changes + +- Added CI test workflow that runs integration tests on pull requests +- Release workflow now verifies binaries on both amd64 and arm64 before releasing diff --git a/README.md b/README.md index f95c020..41544bb 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ File modes (such as the executable bit) are preserved when pushing commits. ## Usage -There are two ways to create signed headless commits with this tool: `push` and `commit`. +There are three commands for creating signed headless commits: `push`, `commit`, and `replay`. Both of these commands take a target owner/repository (eg, `--target/-T DataDog/commit-headless`) and remote branch name (eg, `--branch bot-branch`) as required flags and expect to find a GitHub @@ -120,6 +120,26 @@ Basic usage: git add -A commit-headless commit -T owner/repo --branch feature -m "Update everything" +### commit-headless replay + +The `replay` command replays existing remote commits as signed commits. This is useful when you +have unsigned commits on a branch (e.g., from a bot or action that doesn't support signed commits) +and want to replace them with signed versions. + +The command fetches the remote branch, extracts commits since the specified base, and recreates +them as signed commits. The branch ref is then force-updated to point to the new signed commits. + +Basic usage: + + # Replay all commits since abc123 as signed commits + commit-headless replay -T owner/repo --branch feature --since abc123 + + # With safety check that remote HEAD matches expected value + commit-headless replay -T owner/repo --branch feature --since abc123 --head-sha def456 + +**Warning:** This command force-pushes to the remote branch. The `--since` commit must be an +ancestor of the branch HEAD. + ## Try it! You can easily try `commit-headless` locally. Create a commit with a different author (to diff --git a/action-template/README.md b/action-template/README.md index d59bf77..d0f49df 100644 --- a/action-template/README.md +++ b/action-template/README.md @@ -109,3 +109,27 @@ unrelated histories: message: "Update security policy" command: commit ``` + +## Usage (commit-headless replay) + +The `replay` command replays existing remote commits as signed commits. This is useful when an +earlier step in your workflow creates unsigned commits and you want to replace them with signed +versions. + +``` +- name: Some action that creates unsigned commits + uses: some-org/some-action@v1 + # This action creates commits but they're not signed + +- name: Replay commits as signed + uses: DataDog/commit-headless@action/v%%VERSION%% + with: + branch: ${{ github.ref_name }} + since: ${{ github.sha }} # The commit before the unsigned commits + command: replay +``` + +The `since` input specifies the base commit (exclusive) - all commits after this point will be +replayed as signed commits. The branch is then force-updated to point to the new signed commits. + +**Warning:** This command force-pushes to the remote branch. diff --git a/action-template/action.js b/action-template/action.js index f4b9313..c1fda7a 100644 --- a/action-template/action.js +++ b/action-template/action.js @@ -41,8 +41,8 @@ function main() { const command = process.env.INPUT_COMMAND; - if (!["commit", "push"].includes(command)) { - console.error(`Unknown command '${command}'. Must be one of "commit" or "push".`); + if (!["commit", "push", "replay"].includes(command)) { + console.error(`Unknown command '${command}'. Must be one of "commit", "push", or "replay".`); process.exit(1); } @@ -80,6 +80,15 @@ function main() { if(message !== "") { args.push("--message", message) } } + if (command === "replay") { + const since = process.env["INPUT_SINCE"] || ""; + if(since === "") { + console.error("replay command requires 'since' input"); + process.exit(1); + } + args.push("--since", since); + } + // The Go binary handles GITHUB_OUTPUT directly and uses stdout for logs // with workflow commands (grouping, notices, etc.) const child = childProcess.spawnSync(cmd, args, { diff --git a/action-template/action.yml b/action-template/action.yml index 0d362c1..638b778 100644 --- a/action-template/action.yml +++ b/action-template/action.yml @@ -31,8 +31,10 @@ inputs: description: 'Create the remote branch, using head-sha as the branch point.' default: false command: - description: 'Command to run. One of "commit" or "push"' + description: 'Command to run. One of "commit", "push", or "replay"' required: true + since: + description: 'For replay, the base commit to replay from (exclusive)' dry-run: description: 'Stop processing just before actually making changes to the remote. Note that the pushed_ref output will be a zeroed commit hash.' default: false diff --git a/cmd_replay.go b/cmd_replay.go new file mode 100644 index 0000000..0e27ba4 --- /dev/null +++ b/cmd_replay.go @@ -0,0 +1,139 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" +) + +type ReplayCmd struct { + Target targetFlag `name:"target" short:"T" required:"" help:"Target repository in owner/repo format."` + Branch string `required:"" help:"Name of the target branch on the remote."` + HeadSha string `name:"head-sha" help:"Expected commit sha of the remote branch HEAD (safety check)."` + Since string `required:"" help:"Base commit to replay from (exclusive). Commits after this will be replayed."` + DryRun bool `name:"dry-run" help:"Perform everything except the final remote writes to GitHub."` + + RepoPath string `name:"repo-path" default:"." help:"Path to the local repository. Defaults to the current directory."` +} + +func (c *ReplayCmd) Help() string { + return ` +This command replays existing remote commits as signed commits. It fetches the remote branch, +extracts commits since the specified base, and recreates them as signed commits using the GitHub +API. The branch ref is then force-updated to point to the new signed commits. + +This is useful when you have unsigned commits on a branch (e.g., from a bot or action that doesn't +support signed commits) and want to replace them with signed versions. + +You must provide a GitHub token via the environment in one of the following variables, in preference +order: + + - HEADLESS_TOKEN + - GITHUB_TOKEN + - GH_TOKEN + +Example usage: + + # Replay all commits since abc123 as signed commits + commit-headless replay -T owner/repo --branch feature --since abc123 + + # With safety check that remote HEAD matches expected value + commit-headless replay -T owner/repo --branch feature --since abc123 --head-sha def456 + +The --since commit must be an ancestor of the branch HEAD. The commits between --since and HEAD +will be replayed as signed commits, and the branch will be force-updated to point to the new HEAD. + +WARNING: This command force-pushes to the remote branch. Use with caution. +` +} + +func (c *ReplayCmd) Run() error { + ctx := context.Background() + repo := &Repository{path: c.RepoPath} + owner, repository := c.Target.Owner(), c.Target.Repository() + + token := getToken(os.Getenv) + if token == "" { + return fmt.Errorf("no GitHub token supplied") + } + + client := NewClient(ctx, token, owner, repository, c.Branch) + + // Get the current remote HEAD + remoteHead, err := client.GetHeadCommitHash(ctx) + if err != nil { + return fmt.Errorf("get remote HEAD: %w", err) + } + + // If --head-sha was provided, validate it matches the remote + if c.HeadSha != "" && c.HeadSha != remoteHead { + return fmt.Errorf("remote HEAD %s doesn't match expected --head-sha %s (the branch may have been updated)", remoteHead, c.HeadSha) + } + + // Fetch the remote branch + logger.Printf("Fetching origin/%s...\n", c.Branch) + if err := repo.Fetch(c.Branch); err != nil { + return err + } + + // Get commits between --since and remote HEAD + remoteRef := fmt.Sprintf("origin/%s", c.Branch) + commits, err := repo.CommitsBetween(c.Since, remoteRef) + if err != nil { + return err + } + + if len(commits) == 0 { + logger.Noticef("No commits to replay (--since %s is already at remote HEAD)", c.Since) + return nil + } + + logger.Printf("Found %d commit(s) to replay\n", len(commits)) + + changes, err := repo.Changes(commits...) + if err != nil { + return fmt.Errorf("get changes: %w", err) + } + + // Use force mode since we're replaying existing commits + client.dryrun = c.DryRun + client.force = true + + return replayChanges(ctx, client, c.Since, changes...) +} + +// replayChanges pushes changes with force-update enabled. +// baseCommit is used as the parent for the first replayed commit. +func replayChanges(ctx context.Context, client *Client, baseCommit string, changes ...Change) error { + hashes := []string{} + for i := 0; i < len(changes) && i < 10; i++ { + hashes = append(hashes, changes[i].hash) + } + + if len(changes) >= 10 { + hashes = append(hashes, fmt.Sprintf("...and %d more.", len(changes)-10)) + } + + endGroup := logger.Group(fmt.Sprintf("Replaying to %s/%s (branch: %s)", client.owner, client.repo, client.branch)) + defer endGroup() + + logger.Printf("Commits to replay: %s\n", strings.Join(hashes, ", ")) + logger.Printf("Base commit: %s\n", baseCommit) + + pushed, newHead, err := client.PushChanges(ctx, baseCommit, changes...) + if err != nil { + return err + } else if pushed != len(changes) { + return fmt.Errorf("replayed %d of %d changes", pushed, len(changes)) + } + + logger.Noticef("Replayed %d commit(s) as signed: %s", len(changes), client.compareURL(baseCommit, newHead)) + + // Output the new head reference for capture by callers or GitHub Actions + if err := logger.Output("pushed_ref", newHead); err != nil { + return fmt.Errorf("write output: %w", err) + } + + return nil +} diff --git a/git.go b/git.go index 557ee19..3a77c1a 100644 --- a/git.go +++ b/git.go @@ -12,6 +12,55 @@ type Repository struct { path string } +// Fetch fetches the specified branch from origin. +func (r *Repository) Fetch(branch string) error { + cmd := exec.Command("git", "fetch", "origin", branch) + cmd.Dir = r.path + if err := cmd.Run(); err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return fmt.Errorf("fetch: %s", strings.TrimSpace(string(ee.Stderr))) + } + return fmt.Errorf("fetch: %w", err) + } + return nil +} + +// CommitsBetween returns the commits between base and head, oldest first. +// This is equivalent to `git rev-list --reverse base..head`. +// Returns an error if base is not an ancestor of head. +func (r *Repository) CommitsBetween(base, head string) ([]string, error) { + // First verify that base is an ancestor of head + cmd := exec.Command("git", "merge-base", "--is-ancestor", base, head) + cmd.Dir = r.path + if err := cmd.Run(); err != nil { + if _, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("%s is not an ancestor of %s (histories have diverged)", base, head) + } + return nil, fmt.Errorf("check ancestry: %w", err) + } + + cmd = exec.Command("git", "rev-list", "--reverse", base+".."+head) + cmd.Dir = r.path + out, err := cmd.Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("list commits: %s", strings.TrimSpace(string(ee.Stderr))) + } + return nil, fmt.Errorf("list commits: %w", err) + } + + var commits []string + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + commits = append(commits, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return nil, err + } + + return commits, nil +} + // CommitsSince returns the commits between base and HEAD, oldest first. // This is equivalent to `git rev-list --reverse base..HEAD`. // Returns an error if base is not an ancestor of HEAD. diff --git a/git_test.go b/git_test.go index 9738bef..e8b342b 100644 --- a/git_test.go +++ b/git_test.go @@ -175,6 +175,79 @@ func TestCommitsSince(t *testing.T) { }) } +func TestCommitsBetween(t *testing.T) { + tr := testRepo(t) + + // Create a few commits + requireNoError(t, os.WriteFile(tr.path("file1"), []byte("content1"), 0o644)) + tr.git("add", "-A") + tr.git("commit", "--message", "first commit") + hash1 := strings.TrimSpace(string(tr.git("rev-parse", "HEAD"))) + + requireNoError(t, os.WriteFile(tr.path("file2"), []byte("content2"), 0o644)) + tr.git("add", "-A") + tr.git("commit", "--message", "second commit") + hash2 := strings.TrimSpace(string(tr.git("rev-parse", "HEAD"))) + + requireNoError(t, os.WriteFile(tr.path("file3"), []byte("content3"), 0o644)) + tr.git("add", "-A") + tr.git("commit", "--message", "third commit") + hash3 := strings.TrimSpace(string(tr.git("rev-parse", "HEAD"))) + + r := &Repository{path: tr.root} + + t.Run("commits between first and third", func(t *testing.T) { + commits, err := r.CommitsBetween(hash1, hash3) + requireNoError(t, err) + if len(commits) != 2 { + t.Fatalf("expected 2 commits, got %d: %v", len(commits), commits) + } + if commits[0] != hash2 || commits[1] != hash3 { + t.Errorf("expected [%s, %s], got %v", hash2, hash3, commits) + } + }) + + t.Run("commits between first and second", func(t *testing.T) { + commits, err := r.CommitsBetween(hash1, hash2) + requireNoError(t, err) + if len(commits) != 1 { + t.Fatalf("expected 1 commit, got %d: %v", len(commits), commits) + } + if commits[0] != hash2 { + t.Errorf("expected [%s], got %v", hash2, commits) + } + }) + + t.Run("commits between same commit (none)", func(t *testing.T) { + commits, err := r.CommitsBetween(hash3, hash3) + requireNoError(t, err) + if len(commits) != 0 { + t.Errorf("expected no commits, got %v", commits) + } + }) + + t.Run("diverged history", func(t *testing.T) { + // Create a separate branch with different history + tr.git("checkout", "-b", "other-branch", hash1) + requireNoError(t, os.WriteFile(tr.path("other-file"), []byte("other"), 0o644)) + tr.git("add", "-A") + tr.git("commit", "--message", "commit on other branch") + otherHash := strings.TrimSpace(string(tr.git("rev-parse", "HEAD"))) + + // Go back to main branch + tr.git("checkout", "-") + + // otherHash is not an ancestor of hash3 + _, err := r.CommitsBetween(otherHash, hash3) + if err == nil { + t.Error("expected error for diverged history") + } + if !strings.Contains(err.Error(), "not an ancestor") { + t.Errorf("expected 'not an ancestor' error, got: %v", err) + } + }) +} + func TestStagedChanges(t *testing.T) { tr := testRepo(t) diff --git a/github.go b/github.go index 9b1cd19..494ae38 100644 --- a/github.go +++ b/github.go @@ -37,6 +37,7 @@ type Client struct { branch string dryrun bool + force bool } // NewClient returns a Client configured to make GitHub requests for branch owned by owner/repo on @@ -203,7 +204,7 @@ func (c *Client) PushChange(ctx context.Context, headCommit string, change Chang // Update ref _, _, err = c.git.UpdateRef(ctx, c.owner, c.repo, "refs/heads/"+c.branch, github.UpdateRef{ SHA: commit.GetSHA(), - Force: github.Ptr(false), + Force: github.Ptr(c.force), }) if err != nil { return "", fmt.Errorf("update ref: %w", err) diff --git a/main.go b/main.go index 21bf66e..7b22461 100644 --- a/main.go +++ b/main.go @@ -47,6 +47,7 @@ type remoteFlags struct { type CLI struct { Push PushCmd `cmd:"" help:"Push local commits to the remote."` Commit CommitCmd `cmd:"" help:"Create a commit directly on the remote."` + Replay ReplayCmd `cmd:"" help:"Replay remote commits as signed commits."` Version VersionCmd `cmd:"" help:"Print version information and exit."` } From 018c322bf7b559372e0f1f6a0d7620e288230a04 Mon Sep 17 00:00:00 2001 From: Alex Vidal Date: Fri, 23 Jan 2026 15:42:08 -0600 Subject: [PATCH 10/14] chore: readability updates --- CONTRIBUTING.md | 18 +++- README.md | 183 +++++++++++++++----------------------- action-template/README.md | 107 ++++++++++++---------- 3 files changed, 147 insertions(+), 161 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87b2440..d19191a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,4 +25,20 @@ to be testable. ## Running Tests -Using go test: `go test -v .` +Using go test: `go test -v ./...` + +## Action Releases + +On merge to main, if there's no tagged release for the current version (in `version.go`), a new +tag is created on the action branch. + +The action branch contains prebuilt binaries to avoid Docker-based actions or runtime downloads. +The release workflow uses the built binary to create the action branch commit, providing confidence +that releases work correctly. + +Tags follow the form `action/vVERSION`. See the [release workflow](.github/workflows/release.yml) +for details. + +## Internal Image Releases + +See the internal commit-headless-ci-config repository. diff --git a/README.md b/README.md index 41544bb..b5125b5 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,37 @@ # commit-headless -A binary tool and GitHub Action for creating signed commits from headless workflows +A binary tool and GitHub Action for creating signed commits from headless workflows. -For the Action, please see [the action branch][action-branch] and the associated `action/` -release tags. - -`commit-headless` is focused on turning local commits into signed commits on the remote. It does -this using the GitHub REST API to create commits. When commits are created using the API (instead -of via `git push`), the commits will be signed and verified by GitHub on behalf of the owner of the -credentials used to access the API. +`commit-headless` turns local commits into signed commits on the remote using the GitHub REST API. +When commits are created using the API (instead of via `git push`), they are signed and verified by +GitHub on behalf of the credentials used to access the API. File modes (such as the executable bit) are preserved when pushing commits. -[action-branch]: https://github.com/DataDog/commit-headless/tree/action - -## Usage - -There are three commands for creating signed headless commits: `push`, `commit`, and `replay`. - -Both of these commands take a target owner/repository (eg, `--target/-T DataDog/commit-headless`) -and remote branch name (eg, `--branch bot-branch`) as required flags and expect to find a GitHub -token in one of the following environment variables: - -- HEADLESS_TOKEN -- GITHUB_TOKEN -- GH_TOKEN - -In normal usage, `commit-headless` will print *only* the reference to the last commit created on the -remote, allowing this to easily be captured in a script. - -More on the specifics for each command below. See also: `commit-headless --help` - -### Specifying the expected head commit - -When creating remote commits via API, `commit-headless` must specify the "expected head sha" of the -remote branch. By default, `commit-headless` will query the GitHub API to get the *current* HEAD -commit of the remote branch and use that as the "expected head sha". This introduces some risk, -especially for active branches or long running jobs, as a new commit introduced after the job starts -will not be considered when pushing the new commits. The commit itself will not be replaced, but the -changes it introduces may be lost. +For the GitHub Action, see [the action branch][action-branch] and the associated `action/` release +tags. -For example, consider an auto-formatting job. It runs `gofmt` over the entire codebase. If the job -starts on commit A and formats a file `main.go`, and while the job is running the branch gains -commit B, which adds *new* changes to `main.go`, when the lint job finishes the formatted version of -`main.go` from commit A will be pushed to the remote, and overwrite the changes to `main.go` -introduced in commit B. +[action-branch]: https://github.com/DataDog/commit-headless/tree/action -You can avoid this by specifying `--head-sha`. This will skip auto discovery of the remote branch -HEAD and instead require that the remote branch HEAD matches the value of `--head-sha`. If the -remote branch HEAD does not match `--head-sha`, the push will fail (which is likely what you want). +## Commands -### Creating a new branch +- [push](#push) - Push local commits to the remote as signed commits +- [commit](#commit) - Create a signed commit from staged changes +- [replay](#replay) - Re-sign existing remote commits -Note that, by default, both of these commands expect the remote branch to already exist. If your -workflow primarily works on *new* branches, you should additionally add the `--create-branch` flag -and supply a commit hash to use as a branch point via `--head-sha`. With this flag, -`commit-headless` will create the branch on GitHub from that commit hash if it doesn't already -exist. +All commands require: +- A target repository: `--target/-T owner/repo` +- A branch name: `--branch branch-name` +- A GitHub token in one of: `HEADLESS_TOKEN`, `GITHUB_TOKEN`, or `GH_TOKEN` -Example: `commit-headless [flags...] --head-sha=$(git rev-parse HEAD) --create-branch ...` +On success, `commit-headless` prints only the SHA of the last commit created, allowing easy capture +in scripts. -### commit-headless push +## push The `push` command automatically determines which local commits need to be pushed by comparing -local HEAD with the remote branch HEAD. It then iterates over those commits, extracts the changed -files and commit message, and creates corresponding remote commits. +local HEAD with the remote branch HEAD. It extracts the changed files and commit message from each +local commit and creates corresponding signed commits on the remote. The remote commits will have the original commit message, with a "Co-authored-by" trailer for the original commit author. @@ -80,35 +47,39 @@ Basic usage: # Create a new branch and push local commits to it commit-headless push -T owner/repo --branch new-feature --head-sha abc123 --create-branch -**Note:** The remote HEAD (or `--head-sha` when creating a branch) must be an ancestor of local -HEAD. If the histories have diverged, the push will fail with an error. This ensures you don't -accidentally create broken history when the local checkout is out of sync with the remote. +### Safety check with --head-sha -### commit-headless commit +By default, `commit-headless` queries the GitHub API to get the current HEAD of the remote branch. +This introduces risk on active branches: if a new commit is pushed after your job starts, your +push will overwrite those changes. -The `commit` command creates a single commit on the remote from the currently staged changes, -similar to how `git commit` works. Stage your changes first with `git add`, then run this command -to push them as a signed commit on the remote. +Specifying `--head-sha` adds a safety check: the push fails if the remote HEAD doesn't match the +expected value. -The staged file paths must match the paths on the remote. That is, if you stage "path/to/file.txt" -then the contents of that file will be applied to that same path on the remote. +### Creating a new branch -Staged deletions (`git rm`) are also supported. +By default, the target branch must already exist. To create a new branch, use `--create-branch` +with `--head-sha` specifying the branch point: -Unlike `push`, the `commit` command does not require any relationship between local and remote -history. This makes it useful for broadcasting the same file changes to multiple repositories, -even if they have completely unrelated histories: + commit-headless push -T owner/repo --branch new-feature --head-sha abc123 --create-branch - # Apply the same changes to multiple repositories - git add config.yml security-policy.md - commit-headless commit -T org/repo1 --branch main -m "Update security policy" - commit-headless commit -T org/repo2 --branch main -m "Update security policy" - commit-headless commit -T org/repo3 --branch main -m "Update security policy" +### Diverged history + +The remote HEAD (or `--head-sha` when creating a branch) must be an ancestor of local HEAD. If the +histories have diverged, the push fails to prevent creating broken history. + +## commit + +The `commit` command creates a single signed commit on the remote from your currently staged +changes, similar to `git commit`. Stage your changes with `git add`, then run this command. + +Staged deletions (`git rm`) are also supported. The staged file paths must match the paths on the +remote. Basic usage: # Stage changes and commit to remote - git add README.md .gitlab-ci.yml + git add README.md commit-headless commit -T owner/repo --branch feature -m "Update docs" # Stage a deletion and a new file @@ -116,18 +87,24 @@ Basic usage: git add new-file.txt commit-headless commit -T owner/repo --branch feature -m "Replace old with new" - # Stage all changes and commit - git add -A - commit-headless commit -T owner/repo --branch feature -m "Update everything" +### Broadcasting to multiple repositories + +Unlike `push`, the `commit` command does not require any relationship between local and remote +history. This makes it useful for applying the same changes to multiple repositories: + + git add config.yml security-policy.md + commit-headless commit -T org/repo1 --branch main -m "Update security policy" + commit-headless commit -T org/repo2 --branch main -m "Update security policy" + commit-headless commit -T org/repo3 --branch main -m "Update security policy" -### commit-headless replay +## replay -The `replay` command replays existing remote commits as signed commits. This is useful when you -have unsigned commits on a branch (e.g., from a bot or action that doesn't support signed commits) -and want to replace them with signed versions. +The `replay` command re-signs existing remote commits. This is useful when you have unsigned +commits on a branch (e.g., from a bot or action that doesn't support signed commits) and want to +replace them with signed versions. -The command fetches the remote branch, extracts commits since the specified base, and recreates -them as signed commits. The branch ref is then force-updated to point to the new signed commits. +The command fetches the remote branch, extracts commits since the specified base, recreates them +as signed commits, and force-updates the branch ref. Basic usage: @@ -140,49 +117,29 @@ Basic usage: **Warning:** This command force-pushes to the remote branch. The `--since` commit must be an ancestor of the branch HEAD. -## Try it! +## Try it -You can easily try `commit-headless` locally. Create a commit with a different author (to -demonstrate how commit-headless attributes changes to the original author), and run it with a GitHub -token. - -For example, create a commit locally and push it to a new branch using the parent commit as the -branch point: +Create a local commit and push it to a new branch: ``` cd ~/Code/repo echo "bot commit here" >> README.md git add README.md -git commit --author='A U Thor ' --message="test bot commit" -# Assuming a github token in $GITHUB_TOKEN or $HEADLESS_TOKEN +git commit --author='A U Thor ' -m "test bot commit" + commit-headless push \ - --target=owner/repo \ - --branch=bot-branch \ - --head-sha="$(git rev-parse HEAD^)" \ + -T owner/repo \ + --branch bot-branch \ + --head-sha "$(git rev-parse HEAD^)" \ --create-branch ``` -Or, to push to an existing branch: +The `--head-sha "$(git rev-parse HEAD^)"` tells commit-headless to create the branch from the +parent of your new commit, so only your new commit gets pushed. + +Or push to an existing branch: ``` -commit-headless push --target=owner/repo --branch=existing-branch +commit-headless push -T owner/repo --branch existing-branch ``` -## Action Releases - -On a merge to main, if there's not already a tagged release for the current version (in -`version.go`), a new tag will be created on the action branch. - -The action branch contains prebuilt binaries of `commit-headless` to avoid having to use Docker -based (composite) actions, or to avoid having to download the binary when the action runs. - -Because the workflow uses the rendered action (and the built binary) to create the commit to the -action branch we are fairly safe from releasing a broken version of the action. - -Assuming the previous step works, the workflow will then create a tag of the form `action/vVERSION`. - -For more on the action release, see the [workflow](.github/workflows/release.yml). - -## Internal Image Releases - -See the internal commit-headless-ci-config repository. diff --git a/action-template/README.md b/action-template/README.md index d0f49df..b595605 100644 --- a/action-template/README.md +++ b/action-template/README.md @@ -1,51 +1,72 @@ # commit-headless action -NOTE: This branch contains only the action implementation of `commit-headless`. To view the source -code, see the [main](https://github.com/DataDog/commit-headless/tree/main) branch. +This action creates signed and verified commits on GitHub from a workflow. -This action uses `commit-headless` to support creating signed and verified remote commits from a -GitHub action workflow. +For source code and CLI documentation, see the [main branch](https://github.com/DataDog/commit-headless/tree/main). -For more details on how `commit-headless` works, check the main branch link above. +## Commands -## Usage (commit-headless push) +- [push](#push) - Push local commits as signed commits +- [commit](#commit) - Create a signed commit from staged changes +- [replay](#replay) - Re-sign existing remote commits -The `push` command automatically determines which local commits need to be pushed by comparing -local HEAD with the remote branch HEAD. +## Inputs -``` +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `command` | Command to run: `push`, `commit`, or `replay` | Yes | | +| `branch` | Target branch name | Yes | | +| `token` | GitHub token | No | `${{ github.token }}` | +| `target` | Target repository (owner/repo) | No | `${{ github.repository }}` | +| `head-sha` | Expected HEAD SHA (safety check) or branch point | No | | +| `create-branch` | Create the branch if it doesn't exist | No | `false` | +| `dry-run` | Skip actual remote writes | No | `false` | +| `message` | Commit message (for `commit` command) | No | | +| `author` | Commit author (for `commit` command) | No | github-actions bot | +| `since` | Base commit to replay from (for `replay` command) | No | | +| `working-directory` | Directory to run in | No | | + +## Outputs + +| Output | Description | +|--------|-------------| +| `pushed_ref` | SHA of the last commit created | + +## push + +Push local commits to the remote as signed commits. + +```yaml - name: Create commits run: | - git config --global user.name "A U Thor" - git config --global user.email "author@example.com" + git config user.name "A U Thor" + git config user.email "author@example.com" echo "new file from my bot" >> bot.txt - git add bot.txt && git commit -m"bot commit 1" + git add bot.txt && git commit -m "bot commit 1" echo "another commit" >> bot.txt - git add bot.txt && git commit -m"bot commit 2" + git add bot.txt && git commit -m "bot commit 2" - name: Push commits uses: DataDog/commit-headless@action/v%%VERSION%% with: - token: ${{ github.token }} # default - target: ${{ github.repository }} # default branch: ${{ github.ref_name }} command: push ``` -If you primarily create commits on *new* branches, you'll want to use the `create-branch` option. This -example creates a commit with the current time in a file, and then pushes it to a branch named -`build-timestamp`, creating it from the current commit hash if the branch doesn't exist. +### Creating a new branch -``` +Use `create-branch` with `head-sha` to create the branch if it doesn't exist: + +```yaml - name: Create commits run: | - git config --global user.name "A U Thor" - git config --global user.email "author@example.com" + git config user.name "A U Thor" + git config user.email "author@example.com" - echo "BUILD-TIMESTAMP-RFC3339: $(date --rfc-3339=s)" > last-build.txt - git add last-build.txt && git commit -m"update build timestamp" + echo "BUILD-TIMESTAMP: $(date --rfc-3339=s)" > last-build.txt + git add last-build.txt && git commit -m "update build timestamp" - name: Push commits uses: DataDog/commit-headless@action/v%%VERSION%% @@ -56,18 +77,15 @@ example creates a commit with the current time in a file, and then pushes it to command: push ``` -## Usage (commit-headless commit) - -The `commit` command creates a single commit from staged changes, similar to `git commit`. Stage -your changes with `git add`, then run the action. +## commit -Unlike `push`, the `commit` command does not require any relationship between local and remote -history. This makes it useful for broadcasting the same file changes to multiple repositories. +Create a signed commit from staged changes. Unlike `push`, this doesn't require any relationship +between local and remote history. -``` -- name: Make and stage changes +```yaml +- name: Stage changes run: | - echo "updating contents of bot.txt" >> bot.txt + echo "updating bot.txt" >> bot.txt date --rfc-3339=s >> timestamp git add bot.txt timestamp @@ -78,20 +96,18 @@ history. This makes it useful for broadcasting the same file changes to multiple uses: DataDog/commit-headless@action/v%%VERSION%% with: branch: ${{ github.ref_name }} - author: "A U Thor " # defaults to the github-actions bot account + author: "A U Thor " message: "a commit message" command: commit ``` ### Broadcasting to multiple repositories -The `commit` command can apply the same staged changes to multiple repositories, even if they have -unrelated histories: +Apply the same staged changes to multiple repositories: -``` +```yaml - name: Stage shared configuration - run: | - git add config.yml security-policy.md + run: git add config.yml security-policy.md - name: Update repo1 uses: DataDog/commit-headless@action/v%%VERSION%% @@ -110,26 +126,23 @@ unrelated histories: command: commit ``` -## Usage (commit-headless replay) +## replay -The `replay` command replays existing remote commits as signed commits. This is useful when an -earlier step in your workflow creates unsigned commits and you want to replace them with signed -versions. +Re-sign existing remote commits. Useful when an earlier step creates unsigned commits. -``` +```yaml - name: Some action that creates unsigned commits uses: some-org/some-action@v1 - # This action creates commits but they're not signed - name: Replay commits as signed uses: DataDog/commit-headless@action/v%%VERSION%% with: branch: ${{ github.ref_name }} - since: ${{ github.sha }} # The commit before the unsigned commits + since: ${{ github.sha }} command: replay ``` -The `since` input specifies the base commit (exclusive) - all commits after this point will be -replayed as signed commits. The branch is then force-updated to point to the new signed commits. +The `since` input specifies the base commit (exclusive). All commits after this point are replayed +as signed commits, and the branch is force-updated. **Warning:** This command force-pushes to the remote branch. From 38bc97bb7f40451f22eb509f7cec7b9df0f71f10 Mon Sep 17 00:00:00 2001 From: Alex Vidal Date: Fri, 23 Jan 2026 14:14:03 -0600 Subject: [PATCH 11/14] release: v3.0.0 --- version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.go b/version.go index e74a658..15e0e75 100644 --- a/version.go +++ b/version.go @@ -1,3 +1,3 @@ package main -const VERSION = "2.0.3" +const VERSION = "3.0.0" From 892162c9640d02d477c975c2af1af1bc31798ae9 Mon Sep 17 00:00:00 2001 From: avidal Date: Mon, 23 Mar 2026 14:10:10 -0500 Subject: [PATCH 12/14] feat: add --force to push and commit commands Some callers need to be able to force push. If they have a complex workflow that involves local rebasing, for instance, they should be able to force push. To force push (or commit), you must specify `--head-sha` and `--force`. If set, the remote HEAD check is skipped and instead `--head-sha` is used as the parent for the first created commit (or only commit in the case of the commit command). refs #45 --- .github/workflows/test.yml | 258 +++++++++++++++++++++++++++++++------ README.md | 18 ++- action-template/README.md | 3 +- action-template/action.js | 8 ++ action-template/action.yml | 5 +- cmd_commit.go | 38 +++--- cmd_push.go | 68 +++++----- cmd_replay.go | 30 ++--- main.go | 69 +++++++++- pushchanges.go | 45 +------ 10 files changed, 376 insertions(+), 166 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a1f7f19..2308c45 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,7 +49,61 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - # Test: push command with multiple commits + # Create the test branch. All suites reset to github.sha before running. + - name: Create test branch + run: git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} + + # ---- create-branch ---- + + - name: "Test create-branch: create local commits" + run: | + git reset --hard ${{ github.sha }} + + echo "New branch content" > new-branch-file.txt + git add new-branch-file.txt + git commit -m "test: create-branch commit" + + - name: "Test create-branch: push to new branch" + id: test-create-branch + uses: ./action-template + with: + branch: ${{ env.TEST_BRANCH }}-create + head-sha: ${{ github.sha }} + create-branch: true + command: push + + - name: "Test create-branch: verify output" + run: | + if [ -z "${{ steps.test-create-branch.outputs.pushed_ref }}" ]; then + echo "ERROR: pushed_ref output is empty" + exit 1 + fi + echo "Pushed ref: ${{ steps.test-create-branch.outputs.pushed_ref }}" + + - name: "Test create-branch: verify commit on remote" + run: | + git fetch origin ${{ env.TEST_BRANCH }}-create + + log=$(git log origin/${{ env.TEST_BRANCH }}-create --oneline -2) + echo "Remote log:" + echo "$log" + + if ! echo "$log" | grep -q "test: create-branch commit"; then + echo "ERROR: commit not found on new branch" + exit 1 + fi + + - name: "Test create-branch: cleanup" + if: always() + run: git push origin --delete ${{ env.TEST_BRANCH }}-create || true + + # ---- push ---- + + - name: "Test push: reset" + run: | + git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} --force + git reset --hard ${{ github.sha }} + - name: "Test push: create local commits" run: | echo "First change" > test-file.txt @@ -60,13 +114,11 @@ jobs: git add test-file.txt git commit -m "test: second commit" - - name: "Test push: push commits to new branch" + - name: "Test push: push commits" id: test-push uses: ./action-template with: branch: ${{ env.TEST_BRANCH }} - head-sha: ${{ github.sha }} - create-branch: true command: push - name: "Test push: verify output" @@ -81,7 +133,6 @@ jobs: run: | git fetch origin ${{ env.TEST_BRANCH }} - # Check that both commits exist log=$(git log origin/${{ env.TEST_BRANCH }} --oneline -3) echo "Remote log:" echo "$log" @@ -95,21 +146,162 @@ jobs: exit 1 fi - # Test: push with no changes (should succeed with exit 0) - # After API push, remote commits have different hashes than local commits, - # so we need to sync local with remote first. - - name: "Test push: sync local with remote" + # ---- push (no changes) ---- + + - name: "Test push no-op: reset" + run: | + git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} --force + git reset --hard ${{ github.sha }} + + - name: "Test push no-op: sync local with remote" run: | git fetch origin ${{ env.TEST_BRANCH }} git reset --hard origin/${{ env.TEST_BRANCH }} - - name: "Test push: no changes succeeds" + - name: "Test push no-op: no changes succeeds" + uses: ./action-template + with: + branch: ${{ env.TEST_BRANCH }} + command: push + + # ---- force ---- + + - name: "Test force: reset" + run: | + git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} --force + git reset --hard ${{ github.sha }} + + - name: "Test force: create initial commits" + run: | + echo "Initial change" > test-file.txt + git add test-file.txt + git commit -m "test: initial commit" + + - name: "Test force: push initial commits" + uses: ./action-template + with: + branch: ${{ env.TEST_BRANCH }} + command: push + + - name: "Test force: record pre-force HEAD" + id: pre-force + run: | + git fetch origin ${{ env.TEST_BRANCH }} + echo "head_sha=$(git rev-parse origin/${{ env.TEST_BRANCH }})" >> $GITHUB_OUTPUT + + - name: "Test force: simulate rebase" + run: | + git reset --hard ${{ github.sha }} + + echo "Rebased change 1" > test-file.txt + git add test-file.txt + git commit -m "test: rebased commit 1" + + echo "Rebased change 2" >> test-file.txt + git add test-file.txt + git commit -m "test: rebased commit 2" + + - name: "Test force: force-push rebased commits" + id: test-force uses: ./action-template with: branch: ${{ env.TEST_BRANCH }} + head-sha: ${{ github.sha }} + force: true command: push - # Test: commit command with staged changes + - name: "Test force: verify output" + run: | + if [ -z "${{ steps.test-force.outputs.pushed_ref }}" ]; then + echo "ERROR: pushed_ref output is empty" + exit 1 + fi + echo "Pushed ref: ${{ steps.test-force.outputs.pushed_ref }}" + + - name: "Test force: verify rebased commits on remote" + run: | + git fetch origin ${{ env.TEST_BRANCH }} + + new_head=$(git rev-parse origin/${{ env.TEST_BRANCH }}) + old_head="${{ steps.pre-force.outputs.head_sha }}" + + echo "Old HEAD: $old_head" + echo "New HEAD: $new_head" + + if [ "$new_head" = "$old_head" ]; then + echo "ERROR: HEAD unchanged after force push" + exit 1 + fi + + log=$(git log origin/${{ env.TEST_BRANCH }} --oneline -3) + echo "Remote log:" + echo "$log" + + if ! echo "$log" | grep -q "test: rebased commit 1"; then + echo "ERROR: rebased commit 1 not found on remote" + exit 1 + fi + if ! echo "$log" | grep -q "test: rebased commit 2"; then + echo "ERROR: rebased commit 2 not found on remote" + exit 1 + fi + + if echo "$log" | grep -q "test: initial commit"; then + echo "ERROR: old pre-rebase commit still present" + exit 1 + fi + + # ---- commit create-branch ---- + + - name: "Test commit create-branch: stage changes" + run: | + git reset --hard ${{ github.sha }} + + echo "New branch via commit" > commit-branch-file.txt + git add commit-branch-file.txt + + - name: "Test commit create-branch: commit to new branch" + id: test-commit-create-branch + uses: ./action-template + with: + branch: ${{ env.TEST_BRANCH }}-commit-create + head-sha: ${{ github.sha }} + create-branch: true + message: "test: commit create-branch" + command: commit + + - name: "Test commit create-branch: verify output" + run: | + if [ -z "${{ steps.test-commit-create-branch.outputs.pushed_ref }}" ]; then + echo "ERROR: pushed_ref output is empty" + exit 1 + fi + echo "Pushed ref: ${{ steps.test-commit-create-branch.outputs.pushed_ref }}" + + - name: "Test commit create-branch: verify commit on remote" + run: | + git fetch origin ${{ env.TEST_BRANCH }}-commit-create + + log=$(git log origin/${{ env.TEST_BRANCH }}-commit-create --oneline -2) + echo "Remote log:" + echo "$log" + + if ! echo "$log" | grep -q "test: commit create-branch"; then + echo "ERROR: commit not found on new branch" + exit 1 + fi + + - name: "Test commit create-branch: cleanup" + if: always() + run: git push origin --delete ${{ env.TEST_BRANCH }}-commit-create || true + + # ---- commit ---- + + - name: "Test commit: reset" + run: | + git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} --force + git reset --hard ${{ github.sha }} + - name: "Test commit: stage changes" run: | echo "Committed via commit command" > commit-test.txt @@ -144,7 +336,6 @@ jobs: exit 1 fi - # Test: commit with no staged changes (should succeed with exit 0) - name: "Test commit: no staged changes succeeds" uses: ./action-template with: @@ -152,12 +343,12 @@ jobs: message: "this should not appear" command: commit - # Test: file mode preservation - # Sync local with remote after commit command created new remote commits - - name: "Test modes: sync local with remote" + # ---- file modes ---- + + - name: "Test modes: reset" run: | - git fetch origin ${{ env.TEST_BRANCH }} - git reset --hard origin/${{ env.TEST_BRANCH }} + git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} --force + git reset --hard ${{ github.sha }} - name: "Test modes: create executable" run: | @@ -177,7 +368,6 @@ jobs: run: | git fetch origin ${{ env.TEST_BRANCH }} - # Get the file mode from the remote mode=$(git ls-tree origin/${{ env.TEST_BRANCH }} -- script.sh | awk '{print $1}') echo "File mode on remote: $mode" @@ -187,19 +377,15 @@ jobs: fi echo "Executable bit preserved successfully" - # Test: replay command (re-sign existing commits) - - name: "Test replay: sync and record base" - id: replay-setup - run: | - git fetch origin ${{ env.TEST_BRANCH }} - git reset --hard origin/${{ env.TEST_BRANCH }} + # ---- replay (multiple commits) ---- - # Record the current HEAD as the base for replay - echo "base_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + - name: "Test replay: reset" + run: | + git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} --force + git reset --hard ${{ github.sha }} - name: "Test replay: create commits with regular git push" run: | - # Create commits that will be "unsigned" (pushed via git, not API) echo "replay test 1" > replay-test.txt git add replay-test.txt git commit -m "test: replay commit 1" @@ -208,7 +394,6 @@ jobs: git add replay-test.txt git commit -m "test: replay commit 2" - # Push directly with git (these won't be signed) git push origin HEAD:${{ env.TEST_BRANCH }} - name: "Test replay: record pre-replay HEAD" @@ -222,7 +407,7 @@ jobs: uses: ./action-template with: branch: ${{ env.TEST_BRANCH }} - since: ${{ steps.replay-setup.outputs.base_sha }} + since: ${{ github.sha }} command: replay - name: "Test replay: verify output" @@ -237,7 +422,6 @@ jobs: run: | git fetch origin ${{ env.TEST_BRANCH }} - # The HEAD should be different (new signed commits) new_head=$(git rev-parse origin/${{ env.TEST_BRANCH }}) old_head="${{ steps.pre-replay.outputs.head_sha }}" @@ -249,7 +433,6 @@ jobs: exit 1 fi - # But the commit messages should still be there log=$(git log origin/${{ env.TEST_BRANCH }} --oneline -3) echo "Remote log:" echo "$log" @@ -265,13 +448,12 @@ jobs: echo "Replay test passed: commits were replayed with new hashes" - # Test: replay single commit (common case: one action-generated unsigned commit) - - name: "Test replay single: sync and record base" - id: replay-single-setup + # ---- replay (single commit) ---- + + - name: "Test replay single: reset" run: | - git fetch origin ${{ env.TEST_BRANCH }} - git reset --hard origin/${{ env.TEST_BRANCH }} - echo "base_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} --force + git reset --hard ${{ github.sha }} - name: "Test replay single: create one commit with regular git push" run: | @@ -291,7 +473,7 @@ jobs: uses: ./action-template with: branch: ${{ env.TEST_BRANCH }} - since: ${{ steps.replay-single-setup.outputs.base_sha }} + since: ${{ github.sha }} command: replay - name: "Test replay single: verify" diff --git a/README.md b/README.md index b5125b5..51c5f9c 100644 --- a/README.md +++ b/README.md @@ -63,10 +63,24 @@ with `--head-sha` specifying the branch point: commit-headless push -T owner/repo --branch new-feature --head-sha abc123 --create-branch +### Force-pushing after rebase + +When a branch has been rebased, the local history diverges from the remote and a normal push will +fail. Use `--force` with `--head-sha` to push the rebased commits: + + # After rebasing onto updated main: + commit-headless push -T owner/repo --branch feature \ + --head-sha "$(git rev-parse main)" --force + +The `--head-sha` value is used as the parent of the first pushed commit, bypassing the remote HEAD +check. The branch ref is force-updated even though the push is not a fast-forward. + +`--force` requires `--head-sha` to be set. + ### Diverged history -The remote HEAD (or `--head-sha` when creating a branch) must be an ancestor of local HEAD. If the -histories have diverged, the push fails to prevent creating broken history. +The remote HEAD (or `--head-sha` if `--create-branch` or `--force` is set) must be an ancestor of +local HEAD. If it isn't, the push fails to prevent creating broken history. ## commit diff --git a/action-template/README.md b/action-template/README.md index b595605..47ecde0 100644 --- a/action-template/README.md +++ b/action-template/README.md @@ -19,7 +19,8 @@ For source code and CLI documentation, see the [main branch](https://github.com/ | `token` | GitHub token | No | `${{ github.token }}` | | `target` | Target repository (owner/repo) | No | `${{ github.repository }}` | | `head-sha` | Expected HEAD SHA (safety check) or branch point | No | | -| `create-branch` | Create the branch if it doesn't exist | No | `false` | +| `create-branch` | Create the branch if it doesn't exist (requires `head-sha`) | No | `false` | +| `force` | Force-update the branch ref (requires `head-sha`) | No | `false` | | `dry-run` | Skip actual remote writes | No | `false` | | `message` | Commit message (for `commit` command) | No | | | `author` | Commit author (for `commit` command) | No | github-actions bot | diff --git a/action-template/action.js b/action-template/action.js index c1fda7a..5bf352f 100644 --- a/action-template/action.js +++ b/action-template/action.js @@ -65,6 +65,14 @@ function main() { if(createBranch.toLowerCase() === "true") { args.push("--create-branch") } + const force = process.env["INPUT_FORCE"] || "false" + if(!["true", "false"].includes(force.toLowerCase())) { + console.error(`Invalid value for force (${force}). Must be one of true or false.`); + process.exit(1); + } + + if(force.toLowerCase() === "true") { args.push("--force") } + const dryrun = process.env["INPUT_DRY-RUN"] || "false" if(!["true", "false"].includes(dryrun.toLowerCase())) { console.error(`Invalid value for dry-run (${dryrun}). Must be one of true or false.`); diff --git a/action-template/action.yml b/action-template/action.yml index 638b778..f1c2dbc 100644 --- a/action-template/action.yml +++ b/action-template/action.yml @@ -28,7 +28,10 @@ inputs: head-sha: description: 'Expected commit sha of the remote branch, or the commit sha to branch from.' create-branch: - description: 'Create the remote branch, using head-sha as the branch point.' + description: 'Create the remote branch. Requires head-sha as the branch point.' + default: false + force: + description: 'Force-update the branch ref, allowing non-fast-forward updates. Requires head-sha.' default: false command: description: 'Command to run. One of "commit", "push", or "replay"' diff --git a/cmd_commit.go b/cmd_commit.go index 606ac82..6e1ff03 100644 --- a/cmd_commit.go +++ b/cmd_commit.go @@ -10,9 +10,8 @@ import ( type CommitCmd struct { remoteFlags - RepoPath string `name:"repo-path" default:"." help:"Path to the repository. Defaults to the current directory."` - Author string `help:"Specify an author using the standard 'A U Thor ' format."` - Message []string `short:"m" help:"Specify a commit message. If used multiple times, values are concatenated as separate paragraphs."` + Author string `help:"Specify an author using the standard 'A U Thor ' format."` + Message []string `short:"m" help:"Specify a commit message. If used multiple times, values are concatenated as separate paragraphs."` } func (c *CommitCmd) Help() string { @@ -78,23 +77,28 @@ func (c *CommitCmd) Run() error { } ctx := context.Background() - owner, repository := c.Target.Owner(), c.Target.Repository() - // Validate --head-sha against remote HEAD (same safety check as push command) - if c.HeadSha != "" && !c.CreateBranch { - token := getToken(os.Getenv) - if token == "" { - return fmt.Errorf("no GitHub token supplied") - } - client := NewClient(ctx, token, owner, repository, c.Branch) - remoteHead, err := client.GetHeadCommitHash(ctx) + token := getToken(os.Getenv) + if token == "" { + return fmt.Errorf("no GitHub token supplied") + } + + client := NewClient(ctx, token, c.Target.Owner(), c.Target.Repository(), c.Branch) + client.dryrun = c.DryRun + client.force = c.Force + + baseCommit, err := c.ResolveBaseCommit(ctx, client) + if err != nil { + return err + } + + if c.CreateBranch { + remoteSha, err := client.CreateBranch(ctx, baseCommit) if err != nil { - return fmt.Errorf("get remote HEAD: %w", err) - } - if c.HeadSha != remoteHead { - return fmt.Errorf("remote HEAD %s doesn't match expected --head-sha %s (the branch may have been updated)", remoteHead, c.HeadSha) + return err } + baseCommit = remoteSha } - return pushChanges(ctx, owner, repository, c.Branch, c.HeadSha, c.CreateBranch, c.DryRun, change) + return pushChanges(ctx, client, baseCommit, change) } diff --git a/cmd_push.go b/cmd_push.go index 6a5e46d..196f5ad 100644 --- a/cmd_push.go +++ b/cmd_push.go @@ -8,7 +8,6 @@ import ( type PushCmd struct { remoteFlags - RepoPath string `name:"repo-path" default:"." help:"Path to the repository that contains the commits. Defaults to the current directory."` } func (c *PushCmd) Help() string { @@ -37,13 +36,20 @@ Example usage: # Push with a safety check that remote HEAD matches expected value commit-headless push -T owner/repo --branch feature --head-sha abc123 -When --head-sha is provided without --create-branch, it acts as a safety check: the push will fail -if the remote branch HEAD doesn't match the expected value. This prevents accidentally overwriting -commits that were pushed after your workflow started. + # Force-push rebased commits onto an updated base + commit-headless push -T owner/repo --branch feature --head-sha $(git rev-parse main) --force -The remote HEAD (or --head-sha when creating a branch) must be an ancestor of local HEAD. If the -histories have diverged, the push will fail. This prevents creating broken history when the local -checkout is out of sync with the remote. +When --head-sha is provided without --create-branch or --force, it acts as a safety check: the push +will fail if the remote branch HEAD doesn't match the expected value. This prevents accidentally +overwriting commits that were pushed after your workflow started. + +When --force is used with --head-sha, the branch ref is updated even if the push is not a +fast-forward. The --head-sha value is used as the parent of the first pushed commit, bypassing the +remote HEAD check. This is useful for re-signing commits after a rebase. + +The remote HEAD (or --head-sha when creating a branch or using --force) must be an ancestor of local +HEAD. If the histories have diverged (and --force is not set), the push will fail. This prevents +creating broken history when the local checkout is out of sync with the remote. Note that the pushed commits will not share the same commit sha, and you should avoid operating on the local checkout after running this command. @@ -59,15 +65,22 @@ pushed commits, you should hard reset the local checkout to the remote version a func (c *PushCmd) Run() error { ctx := context.Background() repo := &Repository{path: c.RepoPath} - owner, repository := c.Target.Owner(), c.Target.Repository() - // Determine the base commit (remote HEAD or --head-sha for new branches) - baseCommit, err := c.getBaseCommit(ctx, owner, repository) + token := getToken(os.Getenv) + if token == "" { + return fmt.Errorf("no GitHub token supplied") + } + + client := NewClient(ctx, token, c.Target.Owner(), c.Target.Repository(), c.Branch) + client.dryrun = c.DryRun + client.force = c.Force + + baseCommit, err := c.ResolveBaseCommit(ctx, client) if err != nil { return err } - // Find local commits that aren't on the remote + // Find local commits that aren't on the remote (uses the logical base, before branch creation) commits, err := repo.CommitsSince(baseCommit) if err != nil { return err @@ -83,36 +96,13 @@ func (c *PushCmd) Run() error { return fmt.Errorf("get changes: %w", err) } - return pushChanges(ctx, owner, repository, c.Branch, c.HeadSha, c.CreateBranch, c.DryRun, changes...) -} - -// getBaseCommit returns the commit to use as the base for determining what to push. -// For new branches (--create-branch), this is --head-sha. -// For existing branches, this is the remote HEAD (validated against --head-sha if provided). -func (c *PushCmd) getBaseCommit(ctx context.Context, owner, repository string) (string, error) { if c.CreateBranch { - if c.HeadSha == "" { - return "", fmt.Errorf("--create-branch requires --head-sha to specify the branch point") + remoteSha, err := client.CreateBranch(ctx, baseCommit) + if err != nil { + return err } - return c.HeadSha, nil - } - - // Get the remote branch HEAD - token := getToken(os.Getenv) - if token == "" { - return "", fmt.Errorf("no GitHub token supplied") - } - - client := NewClient(ctx, token, owner, repository, c.Branch) - remoteHead, err := client.GetHeadCommitHash(ctx) - if err != nil { - return "", fmt.Errorf("get remote HEAD: %w", err) - } - - // If --head-sha was provided, validate it matches the remote - if c.HeadSha != "" && c.HeadSha != remoteHead { - return "", fmt.Errorf("remote HEAD %s doesn't match expected --head-sha %s (the branch may have been updated)", remoteHead, c.HeadSha) + baseCommit = remoteSha } - return remoteHead, nil + return pushChanges(ctx, client, baseCommit, changes...) } diff --git a/cmd_replay.go b/cmd_replay.go index 0e27ba4..8056da1 100644 --- a/cmd_replay.go +++ b/cmd_replay.go @@ -8,13 +8,8 @@ import ( ) type ReplayCmd struct { - Target targetFlag `name:"target" short:"T" required:"" help:"Target repository in owner/repo format."` - Branch string `required:"" help:"Name of the target branch on the remote."` - HeadSha string `name:"head-sha" help:"Expected commit sha of the remote branch HEAD (safety check)."` - Since string `required:"" help:"Base commit to replay from (exclusive). Commits after this will be replayed."` - DryRun bool `name:"dry-run" help:"Perform everything except the final remote writes to GitHub."` - - RepoPath string `name:"repo-path" default:"." help:"Path to the local repository. Defaults to the current directory."` + baseFlags + Since string `required:"" help:"Base commit to replay from (exclusive). Commits after this will be replayed."` } func (c *ReplayCmd) Help() string { @@ -51,24 +46,19 @@ WARNING: This command force-pushes to the remote branch. Use with caution. func (c *ReplayCmd) Run() error { ctx := context.Background() repo := &Repository{path: c.RepoPath} - owner, repository := c.Target.Owner(), c.Target.Repository() token := getToken(os.Getenv) if token == "" { return fmt.Errorf("no GitHub token supplied") } - client := NewClient(ctx, token, owner, repository, c.Branch) - - // Get the current remote HEAD - remoteHead, err := client.GetHeadCommitHash(ctx) - if err != nil { - return fmt.Errorf("get remote HEAD: %w", err) - } + client := NewClient(ctx, token, c.Target.Owner(), c.Target.Repository(), c.Branch) + client.dryrun = c.DryRun + client.force = true - // If --head-sha was provided, validate it matches the remote - if c.HeadSha != "" && c.HeadSha != remoteHead { - return fmt.Errorf("remote HEAD %s doesn't match expected --head-sha %s (the branch may have been updated)", remoteHead, c.HeadSha) + // Validate remote HEAD against --head-sha if provided + if _, err := c.ValidateRemoteHead(ctx, client); err != nil { + return err } // Fetch the remote branch @@ -96,10 +86,6 @@ func (c *ReplayCmd) Run() error { return fmt.Errorf("get changes: %w", err) } - // Use force mode since we're replaying existing commits - client.dryrun = c.DryRun - client.force = true - return replayChanges(ctx, client, c.Since, changes...) } diff --git a/main.go b/main.go index 7b22461..923db42 100644 --- a/main.go +++ b/main.go @@ -1,14 +1,17 @@ package main import ( + "context" "fmt" "os" + "regexp" "strings" "github.com/alecthomas/kong" ) var logger *Logger +var hashRegex = regexp.MustCompile(`^[a-f0-9]{4,40}$`) type targetFlag string @@ -35,13 +38,67 @@ func (f targetFlag) Repository() string { return repo } -// flags that are shared among commands that interact with the remote +// baseFlags are shared among all commands that interact with the remote. +type baseFlags struct { + Target targetFlag `name:"target" short:"T" required:"" help:"Target repository in owner/repo format."` + Branch string `required:"" help:"Name of the target branch on the remote."` + HeadSha string `name:"head-sha" help:"Expected commit sha of the remote branch, or the commit sha to branch from."` + DryRun bool `name:"dry-run" help:"Perform everything except the final remote writes to GitHub."` + RepoPath string `name:"repo-path" default:"." help:"Path to the local repository. Defaults to the current directory."` +} + +// ValidateHeadSha checks that --head-sha is a valid full-length hex commit hash, if provided. +func (f *baseFlags) ValidateHeadSha() error { + if f.HeadSha != "" && (!hashRegex.MatchString(f.HeadSha) || len(f.HeadSha) != 40) { + return fmt.Errorf("invalid head-sha %q, must be a full 40 hex digit commit hash", f.HeadSha) + } + return nil +} + +// ValidateRemoteHead fetches the remote branch HEAD and checks it matches --head-sha if provided. +// Returns the remote HEAD hash. +func (f *baseFlags) ValidateRemoteHead(ctx context.Context, client *Client) (string, error) { + remoteHead, err := client.GetHeadCommitHash(ctx) + if err != nil { + return "", fmt.Errorf("get remote HEAD: %w", err) + } + if f.HeadSha != "" && f.HeadSha != remoteHead { + return "", fmt.Errorf("remote HEAD %s doesn't match expected --head-sha %s (the branch may have been updated)", remoteHead, f.HeadSha) + } + return remoteHead, nil +} + +// remoteFlags extends baseFlags with options for push-style commands that create commits. type remoteFlags struct { - Target targetFlag `name:"target" short:"T" required:"" help:"Target repository in owner/repo format."` - Branch string `required:"" help:"Name of the target branch on the remote."` - HeadSha string `name:"head-sha" help:"Expected commit sha of the remote branch, or the commit sha to branch from."` - CreateBranch bool `name:"create-branch" help:"Create the remote branch, requires --head-sha to be set."` - DryRun bool `name:"dry-run" help:"Perform everything except the final remote writes to GitHub."` + baseFlags + CreateBranch bool `name:"create-branch" help:"Create the remote branch, requires --head-sha to be set."` + Force bool `help:"Force-update the branch ref, allowing non-fast-forward updates. Requires --head-sha to be set."` +} + +// ResolveBaseCommit determines the commit to use as the parent of the first pushed commit. +// For --create-branch: returns --head-sha as the branch point. +// For --force: returns --head-sha directly, skipping remote HEAD validation. +// Otherwise: fetches the remote HEAD, validating against --head-sha if provided. +func (f *remoteFlags) ResolveBaseCommit(ctx context.Context, client *Client) (string, error) { + if err := f.ValidateHeadSha(); err != nil { + return "", err + } + + if f.CreateBranch { + if f.HeadSha == "" { + return "", fmt.Errorf("--create-branch requires --head-sha to specify the branch point") + } + return f.HeadSha, nil + } + + if f.Force { + if f.HeadSha == "" { + return "", fmt.Errorf("--force requires --head-sha to specify the new base commit") + } + return f.HeadSha, nil + } + + return f.ValidateRemoteHead(ctx, client) } type CLI struct { diff --git a/pushchanges.go b/pushchanges.go index 0cc443d..57466f4 100644 --- a/pushchanges.go +++ b/pushchanges.go @@ -2,18 +2,14 @@ package main import ( "context" - "errors" "fmt" - "os" - "regexp" "strings" ) -var hashRegex = regexp.MustCompile(`^[a-f0-9]{4,40}$`) - -// Takes a list of changes to push to the remote identified by target. -// Prints the last commit pushed to standard output. -func pushChanges(ctx context.Context, owner, repository, branch, headSha string, createBranch, dryrun bool, changes ...Change) error { +// pushChanges pushes a list of changes using the given client. +// headSha is the resolved parent for the first commit. +// If the client has CreateBranch behavior needed, the caller should have already handled that. +func pushChanges(ctx context.Context, client *Client, headSha string, changes ...Change) error { hashes := []string{} for i := 0; i < len(changes) && i < 10; i++ { hashes = append(hashes, changes[i].hash) @@ -23,41 +19,10 @@ func pushChanges(ctx context.Context, owner, repository, branch, headSha string, hashes = append(hashes, fmt.Sprintf("...and %d more.", len(changes)-10)) } - endGroup := logger.Group(fmt.Sprintf("Pushing to %s/%s (branch: %s)", owner, repository, branch)) + endGroup := logger.Group(fmt.Sprintf("Pushing to %s/%s (branch: %s)", client.owner, client.repo, client.branch)) defer endGroup() logger.Printf("Commits: %s\n", strings.Join(hashes, ", ")) - - if headSha != "" && (!hashRegex.MatchString(headSha) || len(headSha) != 40) { - return fmt.Errorf("invalid head-sha %q, must be a full 40 hex digit commit hash", headSha) - } - - if createBranch && headSha == "" { - return errors.New("cannot use --create-branch without supplying --head-sha") - } - - token := getToken(os.Getenv) - if token == "" { - return errors.New("no GitHub token supplied") - } - - client := NewClient(ctx, token, owner, repository, branch) - client.dryrun = dryrun - - if headSha == "" { - remoteSha, err := client.GetHeadCommitHash(context.Background()) - if err != nil { - return err - } - headSha = remoteSha - } else if createBranch { - remoteSha, err := client.CreateBranch(ctx, headSha) - if err != nil { - return err - } - headSha = remoteSha - } - logger.Printf("Remote head commit: %s\n", headSha) pushed, newHead, err := client.PushChanges(ctx, headSha, changes...) From b9de09c76ec2519d8b2aea9ad9b01925e7c81993 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 23 Mar 2026 15:08:45 -0500 Subject: [PATCH 13/14] feat: verify that created commits are signed with retry+backoff Sometimes commits made by the API are not signed. We've seen this intermittently internally. Instead of allowing unsigned commits to be created, we're now verifying created commits. If a created commit is unsigned, commit-headless will make up to `--sign-attempts` attempts (default 5) with exponential backoff before hard failing. While this will technically create up to 5 new commits on the remote, the actual branch reference is only updated once the signed commit is created. The dangling commits will be removed by GitHub during normal garbage collection. As part of this, we only update the branch pointer after *all* commits are created, not when *each* commit is created. This means a failure in the middle of a push of 5 commits will not leave you with a subset of remote commits. --- README.md | 32 +++++++++-- action-template/README.md | 1 + action-template/action.js | 3 + action-template/action.yml | 3 + cmd_commit.go | 1 + cmd_push.go | 1 + cmd_replay.go | 1 + github.go | 66 +++++++++++++++------- github_test.go | 109 ++++++++++++++++--------------------- main.go | 11 ++-- 10 files changed, 138 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 51c5f9c..899ce1e 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,10 @@ A binary tool and GitHub Action for creating signed commits from headless workflows. -`commit-headless` turns local commits into signed commits on the remote using the GitHub REST API. -When commits are created using the API (instead of via `git push`), they are signed and verified by -GitHub on behalf of the credentials used to access the API. +`commit-headless` turns local commits into commits on the remote using the GitHub REST API. +When commits are created using the API with a GitHub App or installation token (such as +`github.token` in Actions), they are signed and verified by GitHub. Commits created with a personal +access token or OAuth token will not be signed. File modes (such as the executable bit) are preserved when pushing commits. @@ -131,6 +132,26 @@ Basic usage: **Warning:** This command force-pushes to the remote branch. The `--since` commit must be an ancestor of the branch HEAD. +## Signature verification + +By default, `commit-headless` verifies that each commit created via the API is signed by GitHub. +If a commit is not signed, it retries with exponential backoff (1s, 2s, 4s, ...) up to +`--sign-attempts` times (default: 5). + +Whether GitHub signs a commit depends on the token type: + +- **GitHub App / installation tokens** (including `github.token` in Actions): commits are signed + and verified by GitHub. +- **Personal access tokens / OAuth tokens**: commits are not signed. Set `--sign-attempts 0` to + skip verification when using these token types. + +Even with a valid token, GitHub may occasionally fail to sign a commit. This has been observed +internally and is not consistently reproducible. The retry mechanism exists as a safety net for +these transient failures. + +If all attempts are exhausted without a signed commit, `commit-headless` exits with an error. This +ensures unsigned commits are never silently pushed to the remote. + ## Try it Create a local commit and push it to a new branch: @@ -145,7 +166,8 @@ commit-headless push \ -T owner/repo \ --branch bot-branch \ --head-sha "$(git rev-parse HEAD^)" \ - --create-branch + --create-branch \ + --sign-attempts 0 ``` The `--head-sha "$(git rev-parse HEAD^)"` tells commit-headless to create the branch from the @@ -154,6 +176,6 @@ parent of your new commit, so only your new commit gets pushed. Or push to an existing branch: ``` -commit-headless push -T owner/repo --branch existing-branch +commit-headless push -T owner/repo --branch existing-branch --sign-attempts 0 ``` diff --git a/action-template/README.md b/action-template/README.md index 47ecde0..49afe22 100644 --- a/action-template/README.md +++ b/action-template/README.md @@ -21,6 +21,7 @@ For source code and CLI documentation, see the [main branch](https://github.com/ | `head-sha` | Expected HEAD SHA (safety check) or branch point | No | | | `create-branch` | Create the branch if it doesn't exist (requires `head-sha`) | No | `false` | | `force` | Force-update the branch ref (requires `head-sha`) | No | `false` | +| `sign-attempts` | Max attempts to create each commit with a valid signature (0 to skip) | No | `5` | | `dry-run` | Skip actual remote writes | No | `false` | | `message` | Commit message (for `commit` command) | No | | | `author` | Commit author (for `commit` command) | No | github-actions bot | diff --git a/action-template/action.js b/action-template/action.js index 5bf352f..45be209 100644 --- a/action-template/action.js +++ b/action-template/action.js @@ -81,6 +81,9 @@ function main() { if(dryrun.toLowerCase() === "true") { args.push("--dry-run") } + const signAttempts = process.env["INPUT_SIGN-ATTEMPTS"] || ""; + if(signAttempts !== "") { args.push("--sign-attempts", signAttempts) } + if (command === "commit") { const author = process.env["INPUT_AUTHOR"] || ""; const message = process.env["INPUT_MESSAGE"] || ""; diff --git a/action-template/action.yml b/action-template/action.yml index f1c2dbc..739d1f5 100644 --- a/action-template/action.yml +++ b/action-template/action.yml @@ -38,6 +38,9 @@ inputs: required: true since: description: 'For replay, the base commit to replay from (exclusive)' + sign-attempts: + description: 'Max attempts to create each commit with a valid signature. Set to 0 to skip verification.' + default: 5 dry-run: description: 'Stop processing just before actually making changes to the remote. Note that the pushed_ref output will be a zeroed commit hash.' default: false diff --git a/cmd_commit.go b/cmd_commit.go index 6e1ff03..84a92da 100644 --- a/cmd_commit.go +++ b/cmd_commit.go @@ -86,6 +86,7 @@ func (c *CommitCmd) Run() error { client := NewClient(ctx, token, c.Target.Owner(), c.Target.Repository(), c.Branch) client.dryrun = c.DryRun client.force = c.Force + client.signAttempts = c.SignAttempts baseCommit, err := c.ResolveBaseCommit(ctx, client) if err != nil { diff --git a/cmd_push.go b/cmd_push.go index 196f5ad..a9808e6 100644 --- a/cmd_push.go +++ b/cmd_push.go @@ -74,6 +74,7 @@ func (c *PushCmd) Run() error { client := NewClient(ctx, token, c.Target.Owner(), c.Target.Repository(), c.Branch) client.dryrun = c.DryRun client.force = c.Force + client.signAttempts = c.SignAttempts baseCommit, err := c.ResolveBaseCommit(ctx, client) if err != nil { diff --git a/cmd_replay.go b/cmd_replay.go index 8056da1..5564448 100644 --- a/cmd_replay.go +++ b/cmd_replay.go @@ -55,6 +55,7 @@ func (c *ReplayCmd) Run() error { client := NewClient(ctx, token, c.Target.Owner(), c.Target.Repository(), c.Branch) client.dryrun = c.DryRun client.force = true + client.signAttempts = c.SignAttempts // Validate remote HEAD against --head-sha if provided if _, err := c.ValidateRemoteHead(ctx, client); err != nil { diff --git a/github.go b/github.go index 494ae38..08b52c9 100644 --- a/github.go +++ b/github.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "strings" + "time" "github.com/google/go-github/v81/github" "golang.org/x/oauth2" @@ -36,8 +37,9 @@ type Client struct { repo string branch string - dryrun bool - force bool + dryrun bool + force bool + signAttempts int } // NewClient returns a Client configured to make GitHub requests for branch owned by owner/repo on @@ -95,26 +97,36 @@ func (c *Client) CreateBranch(ctx context.Context, headSha string) (string, erro return created.GetObject().GetSHA(), nil } -// PushChanges takes a list of changes and a commit hash and produces commits using the GitHub REST API. -// The commit hash is expected to be the current head of the remote branch, see [GetHeadCommitHash] -// for more. +// PushChanges creates commits for each change, then updates the branch ref once at the end. +// This is all-or-nothing: if any commit fails, the branch ref is not updated. // It returns the number of changes that were successfully pushed, the new head reference hash, and // any error encountered. func (c *Client) PushChanges(ctx context.Context, headCommit string, changes ...Change) (int, string, error) { var err error for i, change := range changes { - headCommit, err = c.PushChange(ctx, headCommit, change) + headCommit, err = c.CreateChange(ctx, headCommit, change) if err != nil { return i + 1, "", fmt.Errorf("push change %d: %w", i+i, err) } } + if !c.dryrun { + _, _, err = c.git.UpdateRef(ctx, c.owner, c.repo, "refs/heads/"+c.branch, github.UpdateRef{ + SHA: headCommit, + Force: github.Ptr(c.force), + }) + if err != nil { + return len(changes), "", fmt.Errorf("update ref: %w", err) + } + } + return len(changes), headCommit, nil } -// PushChange pushes a single change using the REST API. -// It returns the hash of the pushed commit or an error. -func (c *Client) PushChange(ctx context.Context, headCommit string, change Change) (string, error) { +// CreateChange creates a single commit from a change using the REST API. +// It does not update the branch ref — that is done by PushChanges after all commits succeed. +// It returns the hash of the created commit or an error. +func (c *Client) CreateChange(ctx context.Context, headCommit string, change Change) (string, error) { shortHash := change.hash if len(shortHash) > 8 { shortHash = shortHash[:8] @@ -186,28 +198,44 @@ func (c *Client) PushChange(ctx context.Context, headCommit string, change Chang return "", fmt.Errorf("create tree: %w", err) } - // Create commit + // Create commit (with signature verification retry) message := change.Headline() if body := change.Body(); body != "" { message = message + "\n\n" + body } - commit, _, err := c.git.CreateCommit(ctx, c.owner, c.repo, github.Commit{ + commitInput := github.Commit{ Message: github.Ptr(message), Tree: &github.Tree{SHA: tree.SHA}, Parents: []*github.Commit{{SHA: github.Ptr(headCommit)}}, - }, nil) + } + + commit, _, err := c.git.CreateCommit(ctx, c.owner, c.repo, commitInput, nil) if err != nil { return "", fmt.Errorf("create commit: %w", err) } - // Update ref - _, _, err = c.git.UpdateRef(ctx, c.owner, c.repo, "refs/heads/"+c.branch, github.UpdateRef{ - SHA: commit.GetSHA(), - Force: github.Ptr(c.force), - }) - if err != nil { - return "", fmt.Errorf("update ref: %w", err) + if c.signAttempts > 0 { + backoff := 1 * time.Second + for attempt := 1; attempt <= c.signAttempts; attempt++ { + if commit.GetVerification().GetVerified() { + break + } + + if attempt == c.signAttempts { + reason := commit.GetVerification().GetReason() + return "", fmt.Errorf("commit %s was not signed after %d attempt(s) (reason: %s)", commit.GetSHA(), c.signAttempts, reason) + } + + logger.Warningf("Commit %s not signed (attempt %d/%d, reason: %s), retrying in %s...", commit.GetSHA(), attempt, c.signAttempts, commit.GetVerification().GetReason(), backoff) + time.Sleep(backoff) + backoff *= 2 + + commit, _, err = c.git.CreateCommit(ctx, c.owner, c.repo, commitInput, nil) + if err != nil { + return "", fmt.Errorf("create commit (attempt %d): %w", attempt+1, err) + } + } } commitSha := commit.GetSHA() diff --git a/github_test.go b/github_test.go index 9dd00cf..e70735d 100644 --- a/github_test.go +++ b/github_test.go @@ -31,7 +31,7 @@ func TestBuildTreeEntries(t *testing.T) { }, } - // Build tree entries the same way PushChange does (without making API calls) + // Build tree entries the same way CreateChange does (without making API calls) var addedPaths, deletedPaths []string for path, fe := range change.entries { if fe.Content == nil { @@ -190,7 +190,7 @@ func TestCreateBranch(t *testing.T) { }) } -func TestPushChange(t *testing.T) { +func TestCreateChange(t *testing.T) { t.Run("dry run returns zero hash", func(t *testing.T) { client := &Client{ owner: "test-owner", @@ -207,7 +207,7 @@ func TestPushChange(t *testing.T) { }, } - sha, err := client.PushChange(context.Background(), "head-sha", change) + sha, err := client.CreateChange(context.Background(), "head-sha", change) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -223,7 +223,6 @@ func TestPushChange(t *testing.T) { createBlobCalled bool createTreeCalled bool commitCalled bool - updateRefCalled bool ) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -267,14 +266,6 @@ func TestPushChange(t *testing.T) { SHA: github.Ptr("new-commit-sha"), }) - case r.Method == http.MethodPatch && strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/refs/"): - updateRefCalled = true - json.NewEncoder(w).Encode(github.Reference{ - Object: &github.GitObject{ - SHA: github.Ptr("new-commit-sha"), - }, - }) - default: t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) w.WriteHeader(http.StatusNotFound) @@ -291,7 +282,7 @@ func TestPushChange(t *testing.T) { }, } - sha, err := client.PushChange(context.Background(), "parent-sha", change) + sha, err := client.CreateChange(context.Background(), "parent-sha", change) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -311,9 +302,6 @@ func TestPushChange(t *testing.T) { if !commitCalled { t.Error("CreateCommit was not called") } - if !updateRefCalled { - t.Error("UpdateRef was not called") - } }) t.Run("successful push with file deletion", func(t *testing.T) { @@ -372,7 +360,7 @@ func TestPushChange(t *testing.T) { }, } - _, err := client.PushChange(context.Background(), "parent-sha", change) + _, err := client.CreateChange(context.Background(), "parent-sha", change) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -432,7 +420,7 @@ func TestPushChange(t *testing.T) { entries: map[string]FileEntry{"file.txt": newFileEntry([]byte("x"))}, } - _, err := client.PushChange(context.Background(), "parent", change) + _, err := client.CreateChange(context.Background(), "parent", change) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -458,7 +446,7 @@ func TestPushChange(t *testing.T) { entries: map[string]FileEntry{"file.txt": newFileEntry([]byte("x"))}, } - _, err := client.PushChange(context.Background(), "nonexistent", change) + _, err := client.CreateChange(context.Background(), "nonexistent", change) if err == nil { t.Fatal("expected error, got nil") } @@ -491,7 +479,7 @@ func TestPushChange(t *testing.T) { entries: map[string]FileEntry{"file.txt": newFileEntry([]byte("x"))}, } - _, err := client.PushChange(context.Background(), "parent", change) + _, err := client.CreateChange(context.Background(), "parent", change) if err == nil { t.Fatal("expected error, got nil") } @@ -525,7 +513,7 @@ func TestPushChange(t *testing.T) { entries: map[string]FileEntry{"file.txt": newFileEntry([]byte("x"))}, } - _, err := client.PushChange(context.Background(), "parent", change) + _, err := client.CreateChange(context.Background(), "parent", change) if err == nil { t.Fatal("expected error, got nil") } @@ -562,7 +550,7 @@ func TestPushChange(t *testing.T) { entries: map[string]FileEntry{"file.txt": newFileEntry([]byte("x"))}, } - _, err := client.PushChange(context.Background(), "parent", change) + _, err := client.CreateChange(context.Background(), "parent", change) if err == nil { t.Fatal("expected error, got nil") } @@ -571,45 +559,6 @@ func TestPushChange(t *testing.T) { } }) - t.Run("update ref fails", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - switch { - case strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/commits/"): - json.NewEncoder(w).Encode(github.Commit{ - Tree: &github.Tree{SHA: github.Ptr("tree-sha")}, - }) - case r.URL.Path == "/repos/test-owner/test-repo/git/blobs": - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(github.Blob{SHA: github.Ptr("blob-sha")}) - case r.URL.Path == "/repos/test-owner/test-repo/git/trees": - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(github.Tree{SHA: github.Ptr("tree-sha")}) - case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/commits": - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(github.Commit{SHA: github.Ptr("commit-sha")}) - case r.Method == http.MethodPatch: - w.WriteHeader(http.StatusConflict) - } - })) - defer server.Close() - - client := newTestClient(t, server) - change := Change{ - hash: "local", - message: "Test", - entries: map[string]FileEntry{"file.txt": newFileEntry([]byte("x"))}, - } - - _, err := client.PushChange(context.Background(), "parent", change) - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "update ref") { - t.Errorf("expected 'update ref' error, got %q", err.Error()) - } - }) } func TestPushChanges(t *testing.T) { @@ -710,6 +659,44 @@ func TestPushChanges(t *testing.T) { t.Errorf("expected count 2 (failed on second), got %d", count) } }) + + t.Run("update ref fails", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.HasPrefix(r.URL.Path, "/repos/test-owner/test-repo/git/commits/"): + json.NewEncoder(w).Encode(github.Commit{ + Tree: &github.Tree{SHA: github.Ptr("tree-sha")}, + }) + case r.URL.Path == "/repos/test-owner/test-repo/git/blobs": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Blob{SHA: github.Ptr("blob-sha")}) + case r.URL.Path == "/repos/test-owner/test-repo/git/trees": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Tree{SHA: github.Ptr("tree-sha")}) + case r.Method == http.MethodPost && r.URL.Path == "/repos/test-owner/test-repo/git/commits": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(github.Commit{SHA: github.Ptr("commit-sha")}) + case r.Method == http.MethodPatch: + w.WriteHeader(http.StatusConflict) + } + })) + defer server.Close() + + client := newTestClient(t, server) + changes := []Change{ + {hash: "h1", message: "First", entries: map[string]FileEntry{"a.txt": newFileEntry([]byte("a"))}}, + } + + _, _, err := client.PushChanges(context.Background(), "parent", changes...) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "update ref") { + t.Errorf("expected 'update ref' error, got %q", err.Error()) + } + }) } func TestURLHelpers(t *testing.T) { diff --git a/main.go b/main.go index 923db42..2cb947c 100644 --- a/main.go +++ b/main.go @@ -40,11 +40,12 @@ func (f targetFlag) Repository() string { // baseFlags are shared among all commands that interact with the remote. type baseFlags struct { - Target targetFlag `name:"target" short:"T" required:"" help:"Target repository in owner/repo format."` - Branch string `required:"" help:"Name of the target branch on the remote."` - HeadSha string `name:"head-sha" help:"Expected commit sha of the remote branch, or the commit sha to branch from."` - DryRun bool `name:"dry-run" help:"Perform everything except the final remote writes to GitHub."` - RepoPath string `name:"repo-path" default:"." help:"Path to the local repository. Defaults to the current directory."` + Target targetFlag `name:"target" short:"T" required:"" help:"Target repository in owner/repo format."` + Branch string `required:"" help:"Name of the target branch on the remote."` + HeadSha string `name:"head-sha" help:"Expected commit sha of the remote branch, or the commit sha to branch from."` + SignAttempts int `name:"sign-attempts" default:"5" help:"Max attempts to create each commit with a valid signature. Set to 0 to skip verification."` + DryRun bool `name:"dry-run" help:"Perform everything except the final remote writes to GitHub."` + RepoPath string `name:"repo-path" default:"." help:"Path to the local repository. Defaults to the current directory."` } // ValidateHeadSha checks that --head-sha is a valid full-length hex commit hash, if provided. From 4f0a3b66eeb37050adb6a12581d912006d8ccc01 Mon Sep 17 00:00:00 2001 From: avidal Date: Mon, 23 Mar 2026 16:10:22 -0500 Subject: [PATCH 14/14] ci: split up test workflow into distinct jobs --- .github/workflows/test.yml | 334 +++++++++++++++++++++---------------- 1 file changed, 193 insertions(+), 141 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2308c45..e0e0bd5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,68 +23,78 @@ jobs: - name: Run Go tests run: go test -v ./... - integration-tests: + build: runs-on: ubuntu-latest needs: unit-tests - - env: - TEST_BRANCH: test/ci-${{ github.run_id }} - steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - fetch-depth: 0 - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version: '1.24' - - name: Build action + - name: Build binary run: | mkdir -p ./action-template/dist go build -buildvcs=false -o ./action-template/dist/commit-headless-linux-amd64 . - - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + - name: Upload action + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: action + path: action-template/ - # Create the test branch. All suites reset to github.sha before running. - - name: Create test branch - run: git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} + test-create-branch: + runs-on: ubuntu-latest + needs: build + env: &env + TEST_BRANCH: test/ci-${{ github.run_id }}-${{ github.job }} + steps: + - &checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 - # ---- create-branch ---- + - &download + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: action + path: action/ - - name: "Test create-branch: create local commits" + - &configure-git + name: Configure git run: | - git reset --hard ${{ github.sha }} + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + - name: Create local commits + run: | echo "New branch content" > new-branch-file.txt git add new-branch-file.txt git commit -m "test: create-branch commit" - - name: "Test create-branch: push to new branch" - id: test-create-branch - uses: ./action-template + - name: Push to new branch + id: push + uses: ./action/ with: - branch: ${{ env.TEST_BRANCH }}-create + branch: ${{ env.TEST_BRANCH }} head-sha: ${{ github.sha }} create-branch: true command: push - - name: "Test create-branch: verify output" + - name: Verify output run: | - if [ -z "${{ steps.test-create-branch.outputs.pushed_ref }}" ]; then + if [ -z "${{ steps.push.outputs.pushed_ref }}" ]; then echo "ERROR: pushed_ref output is empty" exit 1 fi - echo "Pushed ref: ${{ steps.test-create-branch.outputs.pushed_ref }}" + echo "Pushed ref: ${{ steps.push.outputs.pushed_ref }}" - - name: "Test create-branch: verify commit on remote" + - name: Verify commit on remote run: | - git fetch origin ${{ env.TEST_BRANCH }}-create + git fetch origin ${{ env.TEST_BRANCH }} - log=$(git log origin/${{ env.TEST_BRANCH }}-create --oneline -2) + log=$(git log origin/${{ env.TEST_BRANCH }} --oneline -2) echo "Remote log:" echo "$log" @@ -93,18 +103,25 @@ jobs: exit 1 fi - - name: "Test create-branch: cleanup" + - &cleanup + name: Cleanup if: always() - run: git push origin --delete ${{ env.TEST_BRANCH }}-create || true + run: git push origin --delete ${{ env.TEST_BRANCH }} || true - # ---- push ---- + test-push: + runs-on: ubuntu-latest + needs: build + env: *env + steps: + - *checkout + - *download + - *configure-git - - name: "Test push: reset" - run: | - git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} --force - git reset --hard ${{ github.sha }} + - &create-branch + name: Create test branch + run: git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} - - name: "Test push: create local commits" + - name: Create local commits run: | echo "First change" > test-file.txt git add test-file.txt @@ -114,22 +131,22 @@ jobs: git add test-file.txt git commit -m "test: second commit" - - name: "Test push: push commits" - id: test-push - uses: ./action-template + - name: Push commits + id: push + uses: ./action/ with: branch: ${{ env.TEST_BRANCH }} command: push - - name: "Test push: verify output" + - name: Verify output run: | - if [ -z "${{ steps.test-push.outputs.pushed_ref }}" ]; then + if [ -z "${{ steps.push.outputs.pushed_ref }}" ]; then echo "ERROR: pushed_ref output is empty" exit 1 fi - echo "Pushed ref: ${{ steps.test-push.outputs.pushed_ref }}" + echo "Pushed ref: ${{ steps.push.outputs.pushed_ref }}" - - name: "Test push: verify commits on remote" + - name: Verify commits on remote run: | git fetch origin ${{ env.TEST_BRANCH }} @@ -146,50 +163,62 @@ jobs: exit 1 fi - # ---- push (no changes) ---- + - *cleanup - - name: "Test push no-op: reset" - run: | - git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} --force - git reset --hard ${{ github.sha }} + test-push-no-op: + runs-on: ubuntu-latest + needs: build + env: *env + steps: + - *checkout + - *download + - *configure-git - - name: "Test push no-op: sync local with remote" + - *create-branch + + - name: Sync local with remote run: | git fetch origin ${{ env.TEST_BRANCH }} git reset --hard origin/${{ env.TEST_BRANCH }} - - name: "Test push no-op: no changes succeeds" - uses: ./action-template + - name: No changes succeeds + uses: ./action/ with: branch: ${{ env.TEST_BRANCH }} command: push - # ---- force ---- + - *cleanup - - name: "Test force: reset" - run: | - git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} --force - git reset --hard ${{ github.sha }} + test-force: + runs-on: ubuntu-latest + needs: build + env: *env + steps: + - *checkout + - *download + - *configure-git - - name: "Test force: create initial commits" + - name: Create test branch and push initial commit run: | + git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} + echo "Initial change" > test-file.txt git add test-file.txt git commit -m "test: initial commit" - - name: "Test force: push initial commits" - uses: ./action-template + - name: Push initial commit + uses: ./action/ with: branch: ${{ env.TEST_BRANCH }} command: push - - name: "Test force: record pre-force HEAD" + - name: Record pre-force HEAD id: pre-force run: | git fetch origin ${{ env.TEST_BRANCH }} echo "head_sha=$(git rev-parse origin/${{ env.TEST_BRANCH }})" >> $GITHUB_OUTPUT - - name: "Test force: simulate rebase" + - name: Simulate rebase run: | git reset --hard ${{ github.sha }} @@ -201,24 +230,24 @@ jobs: git add test-file.txt git commit -m "test: rebased commit 2" - - name: "Test force: force-push rebased commits" - id: test-force - uses: ./action-template + - name: Force-push rebased commits + id: force-push + uses: ./action/ with: branch: ${{ env.TEST_BRANCH }} head-sha: ${{ github.sha }} force: true command: push - - name: "Test force: verify output" + - name: Verify output run: | - if [ -z "${{ steps.test-force.outputs.pushed_ref }}" ]; then + if [ -z "${{ steps.force-push.outputs.pushed_ref }}" ]; then echo "ERROR: pushed_ref output is empty" exit 1 fi - echo "Pushed ref: ${{ steps.test-force.outputs.pushed_ref }}" + echo "Pushed ref: ${{ steps.force-push.outputs.pushed_ref }}" - - name: "Test force: verify rebased commits on remote" + - name: Verify rebased commits on remote run: | git fetch origin ${{ env.TEST_BRANCH }} @@ -251,38 +280,45 @@ jobs: exit 1 fi - # ---- commit create-branch ---- + - *cleanup - - name: "Test commit create-branch: stage changes" - run: | - git reset --hard ${{ github.sha }} + test-commit-create-branch: + runs-on: ubuntu-latest + needs: build + env: *env + steps: + - *checkout + - *download + - *configure-git + - name: Stage changes + run: | echo "New branch via commit" > commit-branch-file.txt git add commit-branch-file.txt - - name: "Test commit create-branch: commit to new branch" - id: test-commit-create-branch - uses: ./action-template + - name: Commit to new branch + id: commit + uses: ./action/ with: - branch: ${{ env.TEST_BRANCH }}-commit-create + branch: ${{ env.TEST_BRANCH }} head-sha: ${{ github.sha }} create-branch: true message: "test: commit create-branch" command: commit - - name: "Test commit create-branch: verify output" + - name: Verify output run: | - if [ -z "${{ steps.test-commit-create-branch.outputs.pushed_ref }}" ]; then + if [ -z "${{ steps.commit.outputs.pushed_ref }}" ]; then echo "ERROR: pushed_ref output is empty" exit 1 fi - echo "Pushed ref: ${{ steps.test-commit-create-branch.outputs.pushed_ref }}" + echo "Pushed ref: ${{ steps.commit.outputs.pushed_ref }}" - - name: "Test commit create-branch: verify commit on remote" + - name: Verify commit on remote run: | - git fetch origin ${{ env.TEST_BRANCH }}-commit-create + git fetch origin ${{ env.TEST_BRANCH }} - log=$(git log origin/${{ env.TEST_BRANCH }}-commit-create --oneline -2) + log=$(git log origin/${{ env.TEST_BRANCH }} --oneline -2) echo "Remote log:" echo "$log" @@ -291,39 +327,41 @@ jobs: exit 1 fi - - name: "Test commit create-branch: cleanup" - if: always() - run: git push origin --delete ${{ env.TEST_BRANCH }}-commit-create || true + - *cleanup - # ---- commit ---- + test-commit: + runs-on: ubuntu-latest + needs: build + env: *env + steps: + - *checkout + - *download + - *configure-git - - name: "Test commit: reset" - run: | - git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} --force - git reset --hard ${{ github.sha }} + - *create-branch - - name: "Test commit: stage changes" + - name: Stage changes run: | echo "Committed via commit command" > commit-test.txt git add commit-test.txt - - name: "Test commit: push staged changes" - id: test-commit - uses: ./action-template + - name: Push staged changes + id: commit + uses: ./action/ with: branch: ${{ env.TEST_BRANCH }} message: "test: commit command" command: commit - - name: "Test commit: verify output" + - name: Verify output run: | - if [ -z "${{ steps.test-commit.outputs.pushed_ref }}" ]; then + if [ -z "${{ steps.commit.outputs.pushed_ref }}" ]; then echo "ERROR: pushed_ref output is empty" exit 1 fi - echo "Pushed ref: ${{ steps.test-commit.outputs.pushed_ref }}" + echo "Pushed ref: ${{ steps.commit.outputs.pushed_ref }}" - - name: "Test commit: verify on remote" + - name: Verify on remote run: | git fetch origin ${{ env.TEST_BRANCH }} @@ -336,21 +374,27 @@ jobs: exit 1 fi - - name: "Test commit: no staged changes succeeds" - uses: ./action-template + - name: No staged changes succeeds + uses: ./action/ with: branch: ${{ env.TEST_BRANCH }} message: "this should not appear" command: commit - # ---- file modes ---- + - *cleanup - - name: "Test modes: reset" - run: | - git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} --force - git reset --hard ${{ github.sha }} + test-modes: + runs-on: ubuntu-latest + needs: build + env: *env + steps: + - *checkout + - *download + - *configure-git + + - *create-branch - - name: "Test modes: create executable" + - name: Create executable run: | echo '#!/bin/bash' > script.sh echo 'echo "Hello from script"' >> script.sh @@ -358,13 +402,13 @@ jobs: git add script.sh git commit -m "test: add executable script" - - name: "Test modes: push executable" - uses: ./action-template + - name: Push executable + uses: ./action/ with: branch: ${{ env.TEST_BRANCH }} command: push - - name: "Test modes: verify executable bit preserved" + - name: Verify executable bit preserved run: | git fetch origin ${{ env.TEST_BRANCH }} @@ -377,15 +421,21 @@ jobs: fi echo "Executable bit preserved successfully" - # ---- replay (multiple commits) ---- + - *cleanup - - name: "Test replay: reset" - run: | - git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} --force - git reset --hard ${{ github.sha }} + test-replay: + runs-on: ubuntu-latest + needs: build + env: *env + steps: + - *checkout + - *download + - *configure-git - - name: "Test replay: create commits with regular git push" + - name: Create test branch and push unsigned commits run: | + git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} + echo "replay test 1" > replay-test.txt git add replay-test.txt git commit -m "test: replay commit 1" @@ -396,29 +446,29 @@ jobs: git push origin HEAD:${{ env.TEST_BRANCH }} - - name: "Test replay: record pre-replay HEAD" + - name: Record pre-replay HEAD id: pre-replay run: | git fetch origin ${{ env.TEST_BRANCH }} echo "head_sha=$(git rev-parse origin/${{ env.TEST_BRANCH }})" >> $GITHUB_OUTPUT - - name: "Test replay: replay commits as signed" - id: test-replay - uses: ./action-template + - name: Replay commits as signed + id: replay + uses: ./action/ with: branch: ${{ env.TEST_BRANCH }} since: ${{ github.sha }} command: replay - - name: "Test replay: verify output" + - name: Verify output run: | - if [ -z "${{ steps.test-replay.outputs.pushed_ref }}" ]; then + if [ -z "${{ steps.replay.outputs.pushed_ref }}" ]; then echo "ERROR: pushed_ref output is empty" exit 1 fi - echo "Pushed ref: ${{ steps.test-replay.outputs.pushed_ref }}" + echo "Pushed ref: ${{ steps.replay.outputs.pushed_ref }}" - - name: "Test replay: verify commits were replayed" + - name: Verify commits were replayed run: | git fetch origin ${{ env.TEST_BRANCH }} @@ -448,40 +498,46 @@ jobs: echo "Replay test passed: commits were replayed with new hashes" - # ---- replay (single commit) ---- + - *cleanup - - name: "Test replay single: reset" - run: | - git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} --force - git reset --hard ${{ github.sha }} + test-replay-single: + runs-on: ubuntu-latest + needs: build + env: *env + steps: + - *checkout + - *download + - *configure-git - - name: "Test replay single: create one commit with regular git push" + - name: Create test branch and push one unsigned commit run: | + git push origin ${{ github.sha }}:refs/heads/${{ env.TEST_BRANCH }} + echo "single replay test" > single-replay.txt git add single-replay.txt git commit -m "test: single unsigned commit" git push origin HEAD:${{ env.TEST_BRANCH }} - - name: "Test replay single: record pre-replay HEAD" - id: pre-replay-single + - name: Record pre-replay HEAD + id: pre-replay run: | git fetch origin ${{ env.TEST_BRANCH }} echo "head_sha=$(git rev-parse origin/${{ env.TEST_BRANCH }})" >> $GITHUB_OUTPUT - - name: "Test replay single: replay single commit as signed" - id: test-replay-single - uses: ./action-template + - name: Replay single commit as signed + id: replay + uses: ./action/ with: branch: ${{ env.TEST_BRANCH }} since: ${{ github.sha }} command: replay - - name: "Test replay single: verify" + - name: Verify run: | git fetch origin ${{ env.TEST_BRANCH }} new_head=$(git rev-parse origin/${{ env.TEST_BRANCH }}) - old_head="${{ steps.pre-replay-single.outputs.head_sha }}" + old_head="${{ steps.pre-replay.outputs.head_sha }}" if [ "$new_head" = "$old_head" ]; then echo "ERROR: HEAD unchanged after replay" @@ -498,8 +554,4 @@ jobs: echo "Single commit replay test passed" - # Cleanup: delete test branch - - name: Cleanup test branch - if: always() - run: | - git push origin --delete ${{ env.TEST_BRANCH }} || true + - *cleanup