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
105 changes: 105 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Runs on pushes to main, manages the `action` branch and `action/` tags family.
name: Build and release action

permissions:
contents: read
packages: read

on:
push:
branches:
- 'main'

jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write

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'

- run: go test -v .

- name: build
run: |
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
echo "Current version: $(cat ./dist/VERSION.txt)"

- name: create action branch commit
id: create-commit
run: |

# Copy the new assets to a temporary location that we can recover later
cp -R dist /tmp/release-assets

git switch action

# Remove everything except the git directory
find . -not -path "./.git" -not -path '.' -maxdepth 1 -exec rm -rf {} +

# Bring back the release assets
mv /tmp/release-assets dist

# "Restore" the contents of action-template from the previous ref
git restore --source "${{ github.sha }}" action-template/

# Copy the contents of action-template to the top of the repository
cp action-template/* . && rm -rf action-template

# Replace the VERSION in README.md
sed -i "s/%%VERSION%%/$(cat dist/VERSION.txt)/g" README.md

# Create a commit
# TODO: A merge should have the PR number in the commit headline, if we use the original
# commit message it should back-link
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com>"
git commit \
--all \
--message="Update action from ${{ github.sha }}" \
--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
with:
branch: action
command: push
commits: ${{ steps.create-commit.outputs.sha }}

- name: check release tag
id: check-tag
run: |
TAG="action/v$(cat ./dist/VERSION.txt)"
if git show-ref --tags --verify --quiet "refs/tags/${TAG}"; then
echo "Release tag ${TAG} already exists. Not releasing."
exit 1
fi
echo "tag=${TAG}" >> $GITHUB_OUTPUT

- name: make release
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
github.rest.git.createRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: 'refs/tags/${{ steps.check-tag.outputs.tag }}',
sha: '${{ steps.push-commits.outputs.pushed_sha }}'
});
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# 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 (or dirty files) into signed commits on the
remote. It does this via the GitHub GraphQL API, more specifically the [createCommitOnBranch][mutation]
Expand All @@ -10,6 +13,7 @@ When this API is used with a GitHub App token, the resulting commit will be sign
GitHub on behalf of the application.

[mutation]: https://docs.github.com/en/graphql/reference/mutations#createcommitonbranch
[action-branch]: https://github.com/DataDog/commit-headless/tree/action

## Usage

Expand Down
79 changes: 79 additions & 0 deletions action-template/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# 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 uses `commit-headless` to support creating signed and verified remote commits from a
GitHub action workflow.

For more details on how `commit-headless` works, check the main branch link above.

## Usage (commit-headless push)

If your workflow creates multiple commits and you want to push all of them, you can use
`commit-headless push`:

```
- name: Create commits
id: create-commits
run: |
git config --global user.name "A U Thor"
git config --global user.email "author@example.com"

echo "new file from my bot" >> bot.txt
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"

# List both commit hashes in reverse order, space separated
echo "commits=\"$(git log "${{ github.sha }}".. --format='%H%x00' | tr '\n' ' ')\"" >> $GITHUB_OUTPUT

- 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
commits: "${{ steps.create-commits.outputs.commits }}"
```

## 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`:

```
- name: Change files
id: change-files
run: |
echo "updating contents of bot.txt" >> bot.txt

date --rfc-3339=s >> 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

- name: Create commit
uses: DataDog/commit-headless@action/v%%VERSION%%
with:
token: ${{ github.token }} # default
target: ${{ github.repository }} # default
branch: ${{ github.ref_name }}
author: "A U Thor <author@example.com>" # defaults to the github-actions bot account
message: "a commit message"
command: commit
files: "${{ steps.create-commits.outputs.files }}"
force: true # default false, needs to be true to allow deletion
```
84 changes: 84 additions & 0 deletions action-template/action.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
const childProcess = require('child_process')
const crypto = require('crypto')
const fs = require('fs')
const os = require('os')
const process = require('process')

function chooseBinary() {
const platform = os.platform()
const arch = os.arch()

if (platform === 'linux' && arch === 'x64') {
return `dist/commit-headless-linux-amd64`
}
if (platform === 'linux' && arch === 'arm64') {
return `dist/commit-headless-linux-arm64`
}

console.error(`Unsupported platform (${platform}) and architecture (${arch})`)
process.exit(1)
}

function main() {
const binary = chooseBinary()

const cmd = `${__dirname}/${binary}`

const env = { ...process.env };
env.HEADLESS_TOKEN = process.env.INPUT_TOKEN;

const command = process.env.INPUT_COMMAND;

if (!["commit", "push"].includes(command)) {
console.error(`Unknown command ${command}. Must be one of "commit" or "push".`);
process.exit(1);
}

let args = [
command,
"--target", process.env.INPUT_TARGET,
"--branch", process.env.INPUT_BRANCH
];

if (command === "push") {
args.push(...process.env.INPUT_COMMITS.split(/\s+/));
} else {
const author = process.env["INPUT_AUTHOR"] || "";
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, {
env: env,
stdio: ['ignore', 'pipe', 'inherit'],
})

const exitCode = child.status
if (typeof exitCode === 'number') {
if(exitCode === 0) {
const out = child.stdout.toString();
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(exitCode)
}
process.exit(1)
}

if (require.main === module) {
main()
}
44 changes: 44 additions & 0 deletions action-template/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Create signed commits out of local commits or a set of changed files.

description: |
This GitHub Action was built specifically to simplify 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.
inputs:
token:
description: 'GitHub token'
required: true
default: ${{ github.token }}
target:
description: 'Target owner/repository'
required: true
default: ${{ github.repository }}
branch:
description: 'Target branch name'
required: true
command:
description: 'Command to run. One of "commit" or "push"'
required: true
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:
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>'
message:
description: 'For commit, the commit message'

outputs:
pushed_sha:
description: 'Commit hash of the last commit created'

runs:
using: 'node20'
main: 'action.js'
2 changes: 2 additions & 0 deletions cmd_commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ func (c *CommitCmd) Run() error {
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 {
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.3.0"
const VERSION = "0.4.0"