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
29 changes: 10 additions & 19 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: '1.24'

Expand Down Expand Up @@ -46,7 +46,7 @@ jobs:
binary: commit-headless-linux-arm64

steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: download artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
Expand All @@ -64,7 +64,7 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # needed to make sure we get all tags

Expand Down Expand Up @@ -127,23 +127,14 @@ jobs:
branch: action
command: push

- name: check release tag
id: check-tag
- name: print release instructions
run: |
TAG="action/v$(cat ./dist/VERSION.txt)"
SHA="${{ steps.push-commits.outputs.pushed_ref }}"

if git show-ref --tags --verify --quiet "refs/tags/${TAG}"; then
echo "Release tag ${TAG} already exists. Not releasing."
exit 1
echo "::notice::Release tag ${TAG} already exists. Nothing to do."
exit 0
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_ref }}'
});
echo "::notice::To release, run: git tag ${TAG} ${SHA} && git push origin ${TAG}"
24 changes: 19 additions & 5 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: '1.24'

Expand All @@ -27,9 +27,9 @@ jobs:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: '1.24'

Expand All @@ -51,7 +51,7 @@ jobs:
TEST_BRANCH: test/ci-${{ github.run_id }}-${{ github.job }}
steps:
- &checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0

Expand Down Expand Up @@ -163,6 +163,17 @@ jobs:
exit 1
fi

- &verify-no-tmp-branches
name: Verify no leftover working branches
run: |
tmp_branches=$(git ls-remote --heads origin | grep -- '${{ env.TEST_BRANCH }}--headless-tmp-' || true)
if [ -n "$tmp_branches" ]; then
echo "ERROR: leftover working branches found:"
echo "$tmp_branches"
exit 1
fi
echo "No leftover working branches"

- *cleanup

test-push-no-op:
Expand Down Expand Up @@ -280,6 +291,7 @@ jobs:
exit 1
fi

- *verify-no-tmp-branches
- *cleanup

test-commit-create-branch:
Expand Down Expand Up @@ -421,6 +433,7 @@ jobs:
fi
echo "Executable bit preserved successfully"

- *verify-no-tmp-branches
- *cleanup

test-replay:
Expand Down Expand Up @@ -498,6 +511,7 @@ jobs:

echo "Replay test passed: commits were replayed with new hashes"

- *verify-no-tmp-branches
- *cleanup

test-replay-single:
Expand Down
37 changes: 26 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

A binary tool and GitHub Action for creating signed commits from headless workflows.

`commit-headless` turns local commits into commits on the remote using the GitHub REST API.
When commits are created using the API with a GitHub App or installation token (such as
`github.token` in Actions), they are signed and verified by GitHub. Commits created with a personal
access token or OAuth token will not be signed.
`commit-headless` turns local commits into commits on the remote using the GitHub API.
By default, commits are created using the GraphQL `createCommitOnBranch` mutation, which produces
signed and verified commits regardless of token type. For commits that modify files with non-default
modes (e.g., executables) and a non-user token, the tool automatically falls back to the REST API
to preserve file modes.

File modes (such as the executable bit) are preserved when pushing commits.
File modes (such as the executable bit) are preserved when using the REST API path. The GraphQL API
does not support file modes — all files are treated as regular files (`100644`). For user tokens
(PAT, OAuth, `ghu_`), signing takes priority and the GraphQL path is always used.

For the GitHub Action, see [the action branch][action-branch] and the associated `action/` release
tags.
Expand Down Expand Up @@ -138,12 +141,9 @@ By default, `commit-headless` verifies that each commit created via the API is s
If a commit is not signed, it retries with exponential backoff (1s, 2s, 4s, ...) up to
`--sign-attempts` times (default: 5).

Whether GitHub signs a commit depends on the token type:

- **GitHub App / installation tokens** (including `github.token` in Actions): commits are signed
and verified by GitHub.
- **Personal access tokens / OAuth tokens**: commits are not signed. Set `--sign-attempts 0` to
skip verification when using these token types.
All token types can produce signed commits. The GraphQL API (used by default) produces signed
commits for both user tokens and app tokens. The REST API fallback also produces signed commits
for GitHub App / installation tokens.

Even with a valid token, GitHub may occasionally fail to sign a commit. This has been observed
internally and is not consistently reproducible. The retry mechanism exists as a safety net for
Expand All @@ -152,6 +152,21 @@ these transient failures.
If all attempts are exhausted without a signed commit, `commit-headless` exits with an error. This
ensures unsigned commits are never silently pushed to the remote.

## API strategy

`commit-headless` automatically chooses between the GraphQL and REST APIs on a per-commit basis:

- **GraphQL** (default): Uses `createCommitOnBranch` — fewer API calls, commits are signed
server-side. Does not support file modes (all files are `100644`).
- **REST** (fallback): Uses the Git Database API — more API calls, but preserves file modes.

The REST fallback is used when a commit modifies files with non-default modes (e.g., `100755` for
executables) and the token is not a user token. User tokens (`ghp_`, `gho_`, `ghu_`,
`github_pat_`) always use GraphQL since signing is the priority.

The tool logs which strategy is used for each commit. If the GraphQL path is used for a commit with
non-default file modes, a warning is logged.

## Try it

Create a local commit and push it to a new branch:
Expand Down
4 changes: 4 additions & 0 deletions action-template/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

This action creates signed and verified commits on GitHub from a workflow.

Commits are created using the GraphQL API by default, which produces signed commits for all token
types. When a commit modifies files with non-default modes (e.g., executables) and a non-user token
is used, the action automatically falls back to the REST API to preserve file modes.

For source code and CLI documentation, see the [main branch](https://github.com/DataDog/commit-headless/tree/main).

## Commands
Expand Down
2 changes: 1 addition & 1 deletion action-template/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,5 @@ outputs:
description: 'Commit hash of the last commit created'

runs:
using: 'node20'
using: 'node24'
main: 'action.js'
11 changes: 11 additions & 0 deletions change.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ type Change struct {
entries map[string]FileEntry
}

// HasNonDefaultModes returns true if any file entry has a mode other than the
// default 100644 (regular file). Empty mode is treated as 100644.
func (c Change) HasNonDefaultModes() bool {
for _, fe := range c.entries {
if fe.Mode != "" && fe.Mode != "100644" {
return true
}
}
return false
}

// Splits a commit message on the first blank line
func (c Change) splitMessage() (string, string) {
h, b, _ := strings.Cut(c.message, "\n\n")
Expand Down
22 changes: 22 additions & 0 deletions change_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,28 @@ import (
"testing"
)

func TestHasNonDefaultModes(t *testing.T) {
tests := []struct {
name string
entries map[string]FileEntry
want bool
}{
{"all default", map[string]FileEntry{"a": {Mode: "100644"}}, false},
{"empty mode treated as default", map[string]FileEntry{"a": {Mode: ""}}, false},
{"executable", map[string]FileEntry{"a": {Mode: "100755"}}, true},
{"symlink", map[string]FileEntry{"a": {Mode: "120000"}}, true},
{"mixed", map[string]FileEntry{"a": {Mode: "100644"}, "b": {Mode: "100755"}}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := Change{entries: tt.entries}
if got := c.HasNonDefaultModes(); got != tt.want {
t.Errorf("HasNonDefaultModes() = %v, want %v", got, tt.want)
}
})
}
}

func TestChangeBody(t *testing.T) {
testcases := []struct {
input string
Expand Down
Loading
Loading