Skip to content

Commit e72b033

Browse files
committed
feat!: add --head-sha to better control force pushes
To avoid potentially overwriting changes on the remote, allow callers to specify `--head-sha` (defaulting to the current HEAD on the remote, which is the previous behavior). This mitigates issues with potentially overwriting remote changes on long running jobs or active branches, particularly with jobs that rewrite file contents (such as lint fixes / auto formatting). - Replace --branch-from=<sha> with the pair of --head-sha=<sha> and --create-branch - Skip fetching current remote HEAD if --head-sha is supplied
1 parent 70fbb3a commit e72b033

File tree

10 files changed

+87
-42
lines changed

10 files changed

+87
-42
lines changed

README.md

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,40 @@ token in one of the following environment variables:
3131
- GITHUB_TOKEN
3232
- GH_TOKEN
3333

34-
Note that, by default, both of these commands expect the remote branch to already exist. If your
35-
workflow primarily works on *new* branches, you should additionally add the `--branch-from` flag and
36-
supply a commit hash to use as a branch point. With this flag, `commit-headless` will create the
37-
branch on GitHub from that commit hash if it doesn't already exist.
38-
39-
Example: `commit-headless <command> [flags...] --branch-from=$(git rev-parse main HEAD) ...`
40-
4134
In normal usage, `commit-headless` will print *only* the reference to the last commit created on the
4235
remote, allowing this to easily be captured in a script.
4336

4437
More on the specifics for each command below. See also: `commit-headless <command> --help`
4538

39+
### Specifying the expected head commit
40+
41+
When creating remote commits via API, `commit-headless` must specify the "expected head sha" of the
42+
remote branch. By default, `commit-headless` will query the GitHub API to get the *current* HEAD
43+
commit of the remote branch and use that as the "expected head sha". This introduces some risk,
44+
especially for active branches or long running jobs, as a new commit introduced after the job starts
45+
will not be considered when pushing the new commits. The commit itself will not be replaced, but the
46+
changes it introduces may be lost.
47+
48+
For example, consider an auto-formatting job. It runs `gofmt` over the entire codebase. If the job
49+
starts on commit A and formats a file `main.go`, and while the job is running the branch gains
50+
commit B, which adds *new* changes to `main.go`, when the lint job finishes the formatted version of
51+
`main.go` from commit A will be pushed to the remote, and overwrite the changes to `main.go`
52+
introduced in commit B.
53+
54+
You can avoid this by specifying `--head-sha`. This will skip auto discovery of the remote branch
55+
HEAD and instead require that the remote branch HEAD matches the value of `--head-sha`. If the
56+
remote branch HEAD does not match `--head-sha`, the push will fail (which is likely what you want).
57+
58+
### Creating a new branch
59+
60+
Note that, by default, both of these commands expect the remote branch to already exist. If your
61+
workflow primarily works on *new* branches, you should additionally add the `--create-branch` flag
62+
and supply a commit hash to use as a branch point via `--head-sha`. With this flag,
63+
`commit-headless` will create the branch on GitHub from that commit hash if it doesn't already
64+
exist.
65+
66+
Example: `commit-headless <command> [flags...] --head-sha=$(git rev-parse main HEAD) --create-branch ...`
67+
4668
### commit-headless push
4769

4870
In addition to the required target and branch flags, the `push` command expects a list of commit
@@ -100,7 +122,8 @@ git commit --author='A U Thor <[email protected]>' --message="test bot commit"
100122
HEADLESS_TOKEN=$(ddtool auth github token) commit-headless push \
101123
--target=owner/repo \
102124
--branch=bot-branch \
103-
--branch-from="$(git rev-parse HEAD^)" \ # use the previous commit as our branch point
125+
--head-sha="$(git rev-parse HEAD^)" \ # use the previous commit as our branch point
126+
--create-branch \
104127
"$(git rev-parse HEAD)" # push the commit we just created
105128
```
106129

action-template/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ If your workflow creates multiple commits and you want to push all of them, you
4646
commits: "${{ steps.create-commits.outputs.commits }}"
4747
```
4848

49-
If you primarily create commits on *new* branches, you'll want to use the `branch-from` option. This
49+
If you primarily create commits on *new* branches, you'll want to use the `create-branch` option. This
5050
example creates a commit with the current time in a file, and then pushes it to a branch named
5151
`build-timestamp`, creating it from the current commit hash if the branch doesn't exist.
5252

@@ -67,7 +67,8 @@ example creates a commit with the current time in a file, and then pushes it to
6767
uses: DataDog/commit-headless@action/v%%VERSION%%
6868
with:
6969
branch: build-timestamp
70-
branch-from: ${{ github.sha }}
70+
head-sha: ${{ github.sha }}
71+
create-branch: true
7172
command: push
7273
commits: "${{ steps.create-commits.outputs.commit }}"
7374
```

action-template/action.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,19 @@ function main() {
4646
"--branch", process.env.INPUT_BRANCH
4747
];
4848

49-
const branchFrom = process.env["INPUT_BRANCH-FROM"] || "";
50-
if (branchFrom !== "") {
51-
args.push("--branch-from", branchFrom);
49+
const headSha = process.env["INPUT_HEAD-SHA"] || "";
50+
if (headSha !== "") {
51+
args.push("--head-sha", headSha);
5252
}
5353

54+
const createBranch = process.env["INPUT_CREATE-BRANCH"] || "false"
55+
if(!["true", "false"].includes(createBranch.toLowerCase())) {
56+
console.error(`Invalid value for create-branch (${createBranch}). Must be one of true or false.`);
57+
process.exit(1);
58+
}
59+
60+
if(createBranch.toLowerCase() === "true") { args.push("--create-branch") }
61+
5462
const dryrun = process.env["INPUT_DRY-RUN"] || "false"
5563
if(!["true", "false"].includes(dryrun.toLowerCase())) {
5664
console.error(`Invalid value for dry-run (${dryrun}). Must be one of true or false.`);

action-template/action.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@ inputs:
2121
required: true
2222
working-directory:
2323
description: 'Switch to this directory before running commit-headless. Useful if you need to commit changes to a secondary repository.'
24-
branch-from:
25-
description: 'If necessary, create the remote branch using this commit hash as the branch point.'
24+
head-sha:
25+
description: 'Expected commit sha of the remote branch, or the commit sha to branch from.'
26+
create-branch:
27+
description: 'Create the remote branch, using head-sha as the branch point.'
28+
default: false
2629
command:
2730
description: 'Command to run. One of "commit" or "push"'
2831
required: true

cmd_commit.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,5 +86,5 @@ func (c *CommitCmd) Run() error {
8686

8787
owner, repository := c.Target.Owner(), c.Target.Repository()
8888

89-
return pushChanges(context.Background(), owner, repository, c.Branch, c.BranchFrom, c.DryRun, change)
89+
return pushChanges(context.Background(), owner, repository, c.Branch, c.HeadSha, c.CreateBranch, c.DryRun, change)
9090
}

cmd_push.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,5 @@ func (c *PushCmd) Run() error {
6969

7070
owner, repository := c.Target.Owner(), c.Target.Repository()
7171

72-
return pushChanges(context.Background(), owner, repository, c.Branch, c.BranchFrom, c.DryRun, changes...)
72+
return pushChanges(context.Background(), owner, repository, c.Branch, c.HeadSha, c.CreateBranch, c.DryRun, changes...)
7373
}

github.go

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
"golang.org/x/oauth2"
1313
)
1414

15+
var ErrNoRemoteBranch = errors.New("branch does not exist on the remote")
16+
1517
// Client provides methods for interacting with a remote repository on GitHub
1618
type Client struct {
1719
httpC *http.Client
@@ -61,8 +63,7 @@ func (c *Client) graphqlURL() string {
6163
}
6264

6365
// GetHeadCommitHash returns the current head commit hash for the configured repository and branch
64-
// If the branch does not exist (404 return), we'll attempt to create it from commit branchFrom
65-
func (c *Client) GetHeadCommitHash(ctx context.Context, branchFrom string) (string, error) {
66+
func (c *Client) GetHeadCommitHash(ctx context.Context) (string, error) {
6667
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.branchURL(), nil)
6768
if err != nil {
6869
return "", fmt.Errorf("prepare http request: %w", err)
@@ -75,11 +76,7 @@ func (c *Client) GetHeadCommitHash(ctx context.Context, branchFrom string) (stri
7576
defer resp.Body.Close()
7677

7778
if resp.StatusCode == http.StatusNotFound {
78-
if branchFrom != "" {
79-
return c.createBranch(ctx, branchFrom)
80-
}
81-
82-
return "", fmt.Errorf("branch %q does not exist on the remote", c.branch)
79+
return "", fmt.Errorf("get branch %q: %w", ErrNoRemoteBranch)
8380
}
8481

8582
if resp.StatusCode != http.StatusOK {
@@ -99,15 +96,15 @@ func (c *Client) GetHeadCommitHash(ctx context.Context, branchFrom string) (stri
9996
return payload.Commit.Sha, nil
10097
}
10198

102-
// createBranch attempts to create c.branch using branchFrom as the branch point
103-
func (c *Client) createBranch(ctx context.Context, branchFrom string) (string, error) {
104-
log("Creating branch from commit %s\n", branchFrom)
99+
// CreateBranch attempts to create c.branch using headSha as the branch point
100+
func (c *Client) CreateBranch(ctx context.Context, headSha string) (string, error) {
101+
log("Creating branch from commit %s\n", headSha)
105102

106103
var input bytes.Buffer
107104

108105
err := json.NewEncoder(&input).Encode(map[string]string{
109106
"ref": fmt.Sprintf("refs/heads/%s", c.branch),
110-
"sha": branchFrom,
107+
"sha": headSha,
111108
})
112109
if err != nil {
113110
return "", err
@@ -256,7 +253,6 @@ func (c *Client) PushChange(ctx context.Context, headCommit string, change Chang
256253
log(" - %s\n", e.Message)
257254
}
258255

259-
log("\nInput data, for reference: %s", string(queryJSON))
260256
return "", errors.New("graphql response")
261257
}
262258

main.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,11 @@ func (f targetFlag) Repository() string {
4242

4343
// flags that are shared among commands that interact with the remote
4444
type remoteFlags struct {
45-
Target targetFlag `name:"target" short:"T" required:"" help:"Target repository in owner/repo format."`
46-
Branch string `required:"" help:"Name of the target branch on the remote."`
47-
BranchFrom string `help:"If necessary, create the remote branch using this commit sha."`
48-
DryRun bool `name:"dry-run" help:"Perform everything except the final remote writes to GitHub."`
45+
Target targetFlag `name:"target" short:"T" required:"" help:"Target repository in owner/repo format."`
46+
Branch string `required:"" help:"Name of the target branch on the remote."`
47+
HeadSha string `name:"head-sha" help:"Expected commit sha of the remote branch, or the commit sha to branch from."`
48+
CreateBranch bool `name:"create-branch" help:"Create the remote branch, requires --head-sha to be set."`
49+
DryRun bool `name:"dry-run" help:"Perform everything except the final remote writes to GitHub."`
4950
}
5051

5152
type CLI struct {

pushchanges.go

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010

1111
// Takes a list of changes to push to the remote identified by target.
1212
// Prints the last commit pushed to standard output.
13-
func pushChanges(ctx context.Context, owner, repository, branch, branchFrom string, dryrun bool, changes ...Change) error {
13+
func pushChanges(ctx context.Context, owner, repository, branch, headSha string, createBranch, dryrun bool, changes ...Change) error {
1414
hashes := []string{}
1515
for i := 0; i < len(changes) && i < 10; i++ {
1616
hashes = append(hashes, changes[i].hash)
@@ -25,8 +25,12 @@ func pushChanges(ctx context.Context, owner, repository, branch, branchFrom stri
2525
log("Branch: %s\n", branch)
2626
log("Commits: %s\n", strings.Join(hashes, ", "))
2727

28-
if branchFrom != "" && (!hashRegex.MatchString(branchFrom) || len(branchFrom) != 40) {
29-
return fmt.Errorf("cannot branch from %q, must be a full 40 hex digit commit hash", branchFrom)
28+
if headSha != "" && (!hashRegex.MatchString(headSha) || len(headSha) != 40) {
29+
return fmt.Errorf("invalid head-sha %q, must be a full 40 hex digit commit hash", headSha)
30+
}
31+
32+
if createBranch && headSha == "" {
33+
return errors.New("cannot use --create-branch without supplying --head-sha")
3034
}
3135

3236
token := getToken(os.Getenv)
@@ -37,12 +41,21 @@ func pushChanges(ctx context.Context, owner, repository, branch, branchFrom stri
3741
client := NewClient(ctx, token, owner, repository, branch)
3842
client.dryrun = dryrun
3943

40-
headRef, err := client.GetHeadCommitHash(context.Background(), branchFrom)
41-
if err != nil {
42-
return err
44+
if headSha == "" {
45+
remoteSha, err := client.GetHeadCommitHash(context.Background())
46+
if err != nil {
47+
return err
48+
}
49+
headSha = remoteSha
50+
} else if createBranch {
51+
remoteSha, err := client.CreateBranch(ctx, headSha)
52+
if err != nil {
53+
return err
54+
}
55+
headSha = remoteSha
4356
}
4457

45-
log("Current head commit: %s\n", headRef)
58+
log("Remote head commit: %s\n", headSha)
4659
for _, c := range changes {
4760
log("Commit %s\n", c.hash)
4861
log(" Headline: %s\n", c.Headline())
@@ -57,7 +70,7 @@ func pushChanges(ctx context.Context, owner, repository, branch, branchFrom stri
5770
}
5871
}
5972

60-
pushed, newHead, err := client.PushChanges(ctx, headRef, changes...)
73+
pushed, newHead, err := client.PushChanges(ctx, headSha, changes...)
6174
if err != nil {
6275
return err
6376
} else if pushed != len(changes) {

version.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
package main
22

3-
const VERSION = "1.0.0"
3+
const VERSION = "2.0.0"

0 commit comments

Comments
 (0)