Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 45 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,60 @@ GitHub on behalf of the application.

## Usage

Currently, there is one command: `commit-headless push`. It takes a target owner/repository and
remote branch name, as well as a list of commit hashes as arguments *or* a list of commit hashes *in
reverse chronological order (newest first)* on standard input.
There are two ways to create signed headless commits with this tool: `push` and `commit`.

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 <command> --help`

### 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.

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
original commit message. This is because commits created using the GraphQL API do not support
setting the author or committer (they are inferred from the token owner), so adding a
"Co-authored-by" trailer allows the commits to carry attribution to the original (bot) committer.

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. For example output, see the later section.
original commit author.

You can use `commit-headless push` via:

GH_TOKEN=xyz commit-headless push --target datadog/commit-headless --branch bot-branch-remote HASH1 HASH2 HASH3 ...
commit-headless push [flags...] HASH1 HASH2 HASH3 ...

Or, using git log (note `--oneline`):

git log --oneline main.. | GH_TOKEN=xyz commit-headless push --target datadog/commit-headless --branch bot-branch-remote
git log --oneline main.. | commit-headless push [flags...]

### 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.

Note that you cannot delete a file without also adding `--force` for safety reasons.

Examples:

# Commit changes to these two files
commit-headless commit [flags...] -- README.md .gitlab-ci.yml

# 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

# Commit a change with a custom message
commit-headless commit [flags...] -m"ran a pipeline" -- output.txt

## Try it!

Expand Down Expand Up @@ -88,13 +120,14 @@ Prerelease occurs automatically on a push to main, or can be manually triggered
`release:build` job on any branch.

Additionally, on main, the `release:publish` job will run. This job takes the prerelease image and
tags it for release.
tags it for release, as well as produces a CI image with various other tools.

You can view all releases (and prereleases) with crane:

```
$ crane ls registry.ddbuild.io/commit-headless-prerelease
$ crane ls registry.ddbuild.io/commit-headless
$ crane ls registry.ddbuild.io/commit-headless-ci-image
```

Note that the final publish job will fail unless there was also a change to `version.go` to avoid
Expand Down
88 changes: 88 additions & 0 deletions cmd_commit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package main

import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"strings"
)

type CommitCmd struct {
remoteFlags

Author string `help:"Specify an author using the standard 'A U Thor <author@example.com>' 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."`
}

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.

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.

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.

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.

You can supply a commit message via --message/-m and an author via --author/-a. If unspecified,
default values will be used.

Examples:
# Commit changes to these two files
commit-headless commit [flags...] -- README.md .gitlab-ci.yml

# 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

# Commit a change with a custom message
commit-headless commit [flags...] -m"ran a pipeline" -- output.txt
`
}

func (c *CommitCmd) Run() error {
change := Change{
hash: strings.Repeat("0", 40),
author: c.Author,
message: strings.Join(c.Message, "\n\n"),
entries: map[string][]byte{},
}

rootfs := os.DirFS(".")

for _, path := range c.Files {
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)
}

change.entries[path] = []byte{}
continue
} else if err != nil {
return fmt.Errorf("could not open file %q: %w", path, err)
}

contents, err := io.ReadAll(fp)
if err != nil {
return fmt.Errorf("read %q: %w", path, err)
}

change.entries[path] = contents
}

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

return pushChanges(context.Background(), owner, repository, c.Branch, c.DryRun, change)
}
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,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."`
Version VersionCmd `cmd:"" help:"Print version information and exit."`
}

Expand Down
2 changes: 1 addition & 1 deletion version.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package main

const VERSION = "0.2.0"
const VERSION = "0.3.0"