A binary tool and GitHub Action for creating signed commits from headless workflows
For the Action, please see the action branch and the associated action/
release tags. For example usage, see Examples.
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. 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 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.
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
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 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.
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).
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.
Example: commit-headless <command> [flags...] --head-sha=$(git rev-parse main HEAD) --create-branch ...
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 author.
You can use commit-headless push via:
commit-headless push [flags...] HASH1 HASH2 HASH3 ...
Or, using git log (note --oneline):
git log --oneline main.. | commit-headless push [flags...]
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.
Usage example:
# 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
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 current branch as the branch point:
cd ~/Code/repo
echo "bot commit here" >> README.md
git add README.md
git commit --author='A U Thor <author@example.com>' --message="test bot commit"
# Assuming a github token in $GITHUB_TOKEN or $HEADLESS_TOKEN
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
Note: Most examples are currently on internal repositories. However, this repository does use the action to release itself.
The release process has two parts to it: prerelease and publish.
Prerelease occurs automatically on a push to main, or can be manually triggered by the
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, as well as produces a CI image with various other tools.
Note: All images are published to an internal only registry. Public usage should build from source or use the Action.
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
overwriting existing releases.
The action release is simlar to the above process, although driven by a GitHub Workflow (see
.github/workflows/release.yml). When a change is made to the default branch, the contents of
action-template/ are used to create a new commit on the action branch.
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.