Skip to content

Add opt-in GPG-signed commits via GitHub API#53

Open
dragid10 wants to merge 1 commit intosanitizers:masterfrom
dragid10:feat/gpg-signed-commits
Open

Add opt-in GPG-signed commits via GitHub API#53
dragid10 wants to merge 1 commit intosanitizers:masterfrom
dragid10:feat/gpg-signed-commits

Conversation

@dragid10
Copy link
Copy Markdown

@dragid10 dragid10 commented Apr 15, 2026

Summary

Backport commits are now created through GitHub's Git Data REST API, so they are automatically signed by GitHub's web-flow GPG key and show as "Verified". No GPG key management needed — no new permissions required.

Git subprocess operations and GitHub API calls have been extracted into dedicated modules, following the existing one-module-per-API pattern (checks_api.py, comments_api.py, locking_api.py):

  • git_cli.py — git subprocess operations (clone, fetch, cherry-pick, temp ref upload)
  • git_api.py — GitHub Git Data REST API (GitAPI class with get_branch_head_sha, create_commit, create_branch)
  • event_handlers.py — simplified to orchestration; git and API logic moved out

How it works

  1. Cherry-pick runs locally as before (conflict detection, rename handling, --mainline for merge commits)
  2. Tree SHA and commit message are read from the local result
  3. Objects are uploaded to GitHub via a temporary ref (prefixed with the backport branch name for ruleset compatibility, with a random suffix)
  4. Temp ref is deleted
  5. A signed commit is created via POST /repos/{owner}/{repo}/git/commits
  6. The backport branch is created via POST /repos/{owner}/{repo}/git/refs

Ref: #1

Test results

Tested end-to-end with a real GitHub App on dragid10/patchback-test-repo:

  • PR #10 — commit d2cbe04: committer GitHub, verified: true

Test plan

  • End-to-end test with real GitHub App — signed commit shows as Verified
  • Unit tests for GitAPI methods and error handling (23 tests, all passing)
  • Verified all try blocks have a single instruction
  • Verified no unused imports or dead code (pyflakes, vulture)

Note: The pre-commit.ci failure is a pre-existing issue (no .pre-commit-config.yaml in the repo) — same failure on PR #50.

Comment thread patchback/event_handlers.py Outdated
) -> None:
"""Returns a branch with backported PR pushed to GitHub.
*, signed_commits: bool = False,
) -> tuple:
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.

This is a bad type. It's full of typing.Any and contradicts the docstring claim.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

updated to tuple[str, str] | None

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.

Still a bad API. I didn't get to record all the feedback earlier. If you want to return a bunch of related things, have an object for that. A tuple with arbitrary strings isn't really usable. Although, I'm yet to check the other places for why you thought it's needed.

@webknjaz
Copy link
Copy Markdown
Member

Adds a signed_commits setting (default false) to the per-repo .github/patchback.yml config

What's the justification for having this optional? Why do we need a feature toggle?

@webknjaz webknjaz linked an issue Apr 15, 2026 that may be closed by this pull request
Comment thread patchback/event_handlers.py Outdated
Comment on lines +510 to +513
# Create a signed commit via the GitHub API and point a new
# branch at it. Commits created through the Git Data API are
# automatically signed by GitHub's web-flow GPG key and show
# as "Verified".
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.

Not sure if this bunch of logic probably belongs on this abstraction layer.

Comment thread patchback/event_handlers.py Outdated
# branch at it. Commits created through the Git Data API are
# automatically signed by GitHub's web-flow GPG key and show
# as "Verified".
tree_sha, commit_message = sync_result
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.

An API with hopefully-in-sync-number-of-tuple-items isn't exactly nice to work with. This should be an object. We probably need to move Git REST API interaction into a dedicated class, just like with the comments, the statuses and similar things.

@dragid10
Copy link
Copy Markdown
Author

Adds a signed_commits setting (default false) to the per-repo .github/patchback.yml config

What's the justification for having this optional? Why do we need a feature toggle?

I imagine most wouldn't care if this was the default setting. but since going through the GitHub web signing changes what the committer actually looks like (changes to GitHub), if they were not aware of this change, they might think it odd.

And I've also learned over the years that some people just want the choice regardless of if they exercise it or not 😅
Not particularly strong justification, but it felt nicer to treat it as opt-in

Comment thread patchback/event_handlers.py Outdated
# automatically signed by GitHub's web-flow GPG key and show
# as "Verified".
tree_sha, commit_message = sync_result
try:
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.

try-blocks must have one single instruction. Otherwise, this causes multiple poorly-visible error handling branches that can originate on multiple lines that are difficult to guess just by looking at them.

Comment thread patchback/event_handlers.py Outdated
pr_number, target_branch,
)
await pr_reporter.finish_reporting(
subtitle='💔 cherry-picking failed — could not push',
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.

push? this error message is identical to another place, making it difficult to distinguish between them in the logs and the comments. We should make sure to include the context unique to each situation.

Comment thread patchback/event_handlers.py Outdated
logger.info('Git objects uploaded successfully')
finally:
# Always clean up the temporary ref
try:
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.

Avoid nested try-in-finally. This is difficult to reason about. Additionally, such cases could be abstracted away by a CM.

Comment thread patchback/event_handlers.py Outdated
else:
logger.info('Git objects uploaded successfully')
finally:
# Always clean up the temporary ref
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.

Code should speak for itself. If you feel like the code comment is needed, most of the time it's a sign of abstraction layer leaks or problematic variable naming.

Comment thread patchback/event_handlers.py Outdated
# trees) to GitHub. The ref is deleted immediately after —
# we only need the objects to exist on GitHub so the API
# commit can reference the tree SHA.
temp_ref = f'refs/patchback-tmp/{secrets.token_hex(16)}'
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.

This should probably be at least prefixed the same way as the final branch name. Projects with branch protection rulesets might allow certain patterns but not others. Perhaps, just use the final branch name and append /{a_random_thing} at the end — this will likely avoid most of such cases.

Comment thread patchback/event_handlers.py Outdated
raise PermissionError(
'Current GitHub App installation does not grant '
'sufficient privileges for pushing to '
f'{repo_remote}. Lacking '
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.

This should mention the ref name.

Comment thread patchback/event_handlers.py Outdated
'sufficient privileges for pushing to '
f'{repo_remote}. Lacking '
'`Contents: write` or `Workflows: write` '
'permissions are known to cause this.\n\n'
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 may also want to hint the users to check their rulesets for signs of blocking the pushes.

Comment thread patchback/event_handlers.py Outdated
# Always clean up the temporary ref
try:
spawn_proc(
*git_cmd, 'push', 'origin', f':{temp_ref}',
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.

That's a retro-style command. Today, it's git push -d ...

Comment thread patchback/event_handlers.py Outdated
objects via a temporary ref for signed commit creation.

When ``signed_commits`` is enabled, returns a
``(tree_sha, commit_message)`` tuple. Otherwise returns ``None``.
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.

This is probably not needed in the first place. We shouldn't have parts of commit manipulation in disconnected places, anyway.

@webknjaz
Copy link
Copy Markdown
Member

webknjaz commented Apr 15, 2026

Adds a signed_commits setting (default false) to the per-repo .github/patchback.yml config

What's the justification for having this optional? Why do we need a feature toggle?

I imagine most wouldn't care if this was the default setting. but since going through the GitHub web signing changes what the committer actually looks like (changes to GitHub), if they were not aware of this change, they might think it odd.

And I've also learned over the years that some people just want the choice regardless of if they exercise it or not 😅 Not particularly strong justification, but it felt nicer to treat it as opt-in

Let's just get rid of this until somebody asks. I don't want additional complexity that potentially nobody even wants in the first place that I'd be left with to maintain. Less is more.

Also, the committer thing is fine — the author is still the GH App which is what's most important: https://api.github.com/repos/dragid10/patchback-test-repo/commits/9b66d01f772a1fd2ba1c010524c5f24872da1895

@webknjaz webknjaz added the enhancement New feature or request label Apr 15, 2026
@webknjaz
Copy link
Copy Markdown
Member

webknjaz commented Apr 15, 2026

How it works

1. Cherry-pick runs locally as before (conflict detection, rename handling, `--mainline` for merge commits)

2. Tree SHA and commit message are read from the local result

3. Objects are uploaded to GitHub via a temporary ref (deleted immediately after)

4. A signed commit is created via `POST /repos/{owner}/{repo}/git/commits`

5. The backport branch is created via `POST /repos/{owner}/{repo}/git/refs`

I'm wondering if it'd be a good idea to provide this as a part of the utils in octomachinery. But this probably goes into the same bucket as sanitizers/octomachinery#4..

Still, it'd be good to have a well-isolated relocatable piece of logic in a helper for better structuring. The amount of logic in the event_handlers is growing and is rather unhelpful. With the state of the project where we don't have a CI or tests, not even the linters or the workflow tooling, I wouldn't like to keep the cognitive complexity in check.


Note: it'd be good to have a helper capable of going through a chain of commits and signing each of those one by one. This is a little out of the scope but keep it in mind when working on other stuff.

@webknjaz
Copy link
Copy Markdown
Member

I'm done w/ the review round for now. Since you're already looking into this, I'm open to getting a CLAUDE.md with the context in a standalone PR.

Create backport commits through GitHub's Git Data REST API so they
are automatically signed by GitHub's web-flow GPG key and show as
"Verified". The cherry-pick still runs locally via git subprocess.

Extract git subprocess operations into git_cli.py and GitHub API
calls into git_api.py, following the existing one-module-per-API
pattern (checks_api.py, comments_api.py, locking_api.py).

Ref: sanitizers#1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@dragid10 dragid10 force-pushed the feat/gpg-signed-commits branch from 4066988 to b98f3ba Compare April 16, 2026 03:18
@dragid10 dragid10 requested a review from webknjaz April 16, 2026 14:23
@dragid10
Copy link
Copy Markdown
Author

@webknjaz I pushed some new commits to address your feedback!

@webknjaz
Copy link
Copy Markdown
Member

Yeah, I liked some parts of the previous state more. Now, I'll have to find enough time to compose a full review. No ETA for that. FWIW, it's best to have smaller PRs as atomic patches are easier to review and merge-in faster + “no-go” things that may happen to be in the same PR wouldn't be blocking. One huge commit is going to take time to polish.

Comment thread patchback/git_api.py
bad_req_err.status_code != http.client.FORBIDDEN or
str(bad_req_err) != 'Resource not accessible by integration'
):
raise
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.

Does this actually work? What if it's called outside of the exception handling context? Seems rather fragile.

@dragid10
Copy link
Copy Markdown
Author

Yeah, I liked some parts of the previous state more. Now, I'll have to find enough time to compose a full review. No ETA for that. FWIW, it's best to have smaller PRs as atomic patches are easier to review and merge-in faster + “no-go” things that may happen to be in the same PR wouldn't be blocking. One huge commit is going to take time to polish.

okay would you prefer I go with this direction now?

@webknjaz
Copy link
Copy Markdown
Member

I think it's a good idea to identify the smallest improvement you can distill and work on that first. In a standalone PR. We may need to pre-agree on what that would be. This could be moving some helpers into a separate model. This could be agreeing to push the low-level sync operations closer to the subprocess invocations and making more logic async-native.

@webknjaz
Copy link
Copy Markdown
Member

FTR, I've also raised the question of having GH App-bound signing keys that would not require the API dance with some GH folks in private spaces. So long-term, this can end up being simplified if they take on the idea I presented. But probably not in months.

@webknjaz
Copy link
Copy Markdown
Member

I think it's a good idea to identify the smallest improvement you can distill and work on that first. In a standalone PR. We may need to pre-agree on what that would be. This could be moving some helpers into a separate model. This could be agreeing to push the low-level sync operations closer to the subprocess invocations and making more logic async-native.

Apparently, I forgot to click "Submit" yesterday, and this has been sitting unsent in my browser:
A good place to start could be separating refactoring and functional changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[research] Producing GPG-signed commits

2 participants