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 <[email protected]>' --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
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.