Skip to content

feat: add create-signed-commit composite action - #320

Merged
Mrtenz merged 5 commits into
mainfrom
mrtenz/signed-commit-action
Nov 13, 2026
Merged

Mrtenz merged 5 commits into
mainfrom
mrtenz/signed-commit-action

Conversation

@Mrtenz

@Mrtenz Mrtenz commented Nov 12, 2026 •

Copy link
Copy Markdown
Member

Adds a composite action that commits the staged changes through the GitHub REST Git Data API. GitHub signs commits created this way on behalf of the token owner, so a GitHub App installation token produces verified commits. git commit -S can't do this because a token has no private key.

The action detects the staged changes with git diff --cached, uploads each file as a blob (keeping file modes and symlinks), creates a tree and a commit with no author or committer set, and then updates the branch ref without forcing. It creates the branch if it doesn't exist on the remote, and it fails if the remote branch isn't at the checked-out commit. Afterwards it resets the local index onto the signed commit, so later steps don't see the committed changes as pending.

Callers must stage the files with git add first. The token is passed to the final git fetch directly, so the checkout can use persist-credentials: false.


Note

Low Risk
Adds a new reusable CI action only; no runtime app or auth logic changes beyond how workflows commit via the GitHub API.

Overview
Introduces a new composite GitHub Action Create signed commit so workflows can push GitHub-verified commits using a token (e.g. a GitHub App installation token) instead of local git commit -S.

Callers stage changes with git add, then the action reads the index via git diff --cached, uploads blobs through the Git Data API, builds a commit without author/committer (so GitHub sets and signs them), and updates or creates the target branch ref without force-push. It errors when the remote branch is ahead of the checkout, skips empty stages, and does not support submodules. A follow-up step fetches the branch and resets the local checkout onto the new SHA when HEAD is detached or on that branch.

Outputs commit-sha for downstream steps.

Reviewed by Cursor Bugbot for commit 6b4d97b. Bugbot is set up for automated code reviews on this repo. Configure here.

@Mrtenz
Mrtenz marked this pull request as ready for review November 12, 2026 13:37

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

maxBuffer: 100 * 1024 * 1024,
});

const { data: blob } = await github.rest.git.createBlob({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've never had to use these parts of the GitHub API. Interesting 🤔

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Me neither. 😅 But it seems to be the easiest way to create signed commits. The other alternative is the GraphQL createCommitOnBranch mutation, but that doesn't support symlinks or executable files from my understanding. Maybe an edge case, but I want to keep support in this action as broad as possible.


// The author and committer are deliberately omitted. GitHub only
// signs the commit when it sets them itself.
const { data: commit } = await github.rest.git.createCommit({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if this workflow stops midway through? It seems like we would create objects in the repo but they would be orphaned. Is that a concern?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's a concern. They will just be garbage collected by GitHub automatically.

Commit the staged changes through the GitHub REST Git Data API, so that
GitHub signs the commit on behalf of the token owner, such as a GitHub
App. `git commit -S` can't do this because a token has no private key.

The action uses the REST API instead of `createCommitOnBranch` because it
preserves file modes and symlinks and fits any number of changes in one
commit. The `branch` input is required, so a workflow can't push to the
default branch by accident.
Move the final `git fetch` and `git reset` out of the `github-script`
step into their own step, and pass the token to the fetch in the URL.
The action no longer depends on `actions/checkout` persisting
credentials, so callers can use `persist-credentials: false`.

The new step is skipped when there is nothing to commit.
The action listed staged paths with `git diff --cached` but read
contents, modes, and deletions from the working tree. As a result, it
could commit unstaged edits, commit a `git rm --cached` removal as a
modification, and ignore index modes and clean-filter or LFS blobs.

Now the action reads the mode, blob SHA, and status of each change from
`git diff --cached --raw` and uploads the staged blob with
`git cat-file`. It parses the output with a named-group regex instead of
pairing fields by position.
The sync step passes the token in the fetch URL, but the default
`actions/checkout` persists its own `Authorization` header in
`.git/config`. If the first request is challenged, git can send both
headers, and GitHub rejects the duplicate. This happens after the signed
commit has already landed.

Pass `-c http.https://github.com/.extraheader=` to the fetch so only the
token from the URL is sent, whichever token the checkout used.
@Mrtenz
Mrtenz force-pushed the mrtenz/signed-commit-action branch from 03bdbda to 9f7f0a5 Compare November 13, 2026 09:45

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9f7f0a5. Configure here.

The sync step ran `git reset` on whatever HEAD pointed at. When the
signed commit was published to a different branch, the checked-out local
branch ended up ahead of its remote, and a later `git push` could
publish the commit there too. The fetch by SHA also left the
remote-tracking branch stale.

Fetch the published branch into `refs/remotes/origin/<branch>`, and
reset only if HEAD is detached or on that branch. In any other case the
checkout stays unchanged, with the changes still staged.
Comment on lines +62 to +64
core.info('No changes to commit.');
core.setOutput('commit-sha', '');
return;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this preferable to failing or skipping?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure. git commit does exit with 1 if there's nothing to commit, but I feel like this is easier to handle? Thinking about a changelog update workflow for example, we can just call this action regardless of whether there are changes to the changelog or not.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could, it just seems harder to understand in the GitHub UI as it would show up as "completed/passed"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems fine to me? Otherwise we'd need to set continue-on-error or check beforehand if there's anything to commit, which we do right now and exit with 0:

https://github.com/MetaMask/core/blob/5a1311c33f28bc31ea4e76eedf6fc28f1cdfef68/.github/workflows/update-changelogs.yml#L201-L204

@Mrtenz
Mrtenz merged commit f1377ad into main Nov 13, 2026
10 checks passed
@Mrtenz
Mrtenz deleted the mrtenz/signed-commit-action branch November 13, 2026 10:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants