Skip to content

backport: sign commits by publishing via the GitHub GraphQL API - #293

Open
pracucci wants to merge 1 commit into
mainfrom
sign-backport-commits
Open

backport: sign commits by publishing via the GitHub GraphQL API#293
pracucci wants to merge 1 commit into
mainfrom
sign-backport-commits

Conversation

@pracucci

@pracucci pracucci commented May 26, 2026

Copy link
Copy Markdown

Summary

Replace the unsigned git push --set-upstream origin <head> in the backport action with a createCommitOnBranch GraphQL mutation. The cherry-pick still runs locally (preserving the conflict-resolution logic and the betterer fallback), but the resulting commit is published to the new branch via the GitHub API, so it is signed by GitHub's web-flow key and shows as Verified in the UI.

Why

The backport action is consumed by Grafana repos whose CI flows run from GitHub Apps. Apps can't have GPG/SSH signing keys registered against them, so git push from the runner produces an unsigned commit. This is friction wherever signed commits are part of the audit/provenance story for a repository.

The fix is server-side: GitHub's createCommitOnBranch mutation signs commits with the web-flow key automatically — the same mechanism the "Merge pull request" button uses.

How

In backport/backport.ts:

  1. After git switch base, capture the base SHA via git rev-parse HEAD.
  2. Run the cherry-pick exactly as before (preserving the existing conflict + betterer handling). This produces a local commit on the working branch with the right tree.
  3. Build a FileChanges payload by diffing base → HEAD with git diff --no-renames --name-status -z:
    • Ddeletions[{ path }].
    • A / M / Tadditions[{ path, contents (base64 of file bytes) }].
  4. Create the head branch on GitHub via github.git.createRef.
  5. Call createCommitOnBranch via @octokit/graphql (already a transitive dependency) with the file changes and the cherry-pick's commit message. The original author of commitToBackport is preserved as a Co-authored-by: trailer so attribution survives.
  6. Continue with the existing github.pulls.create() etc.

The local git push --set-upstream is removed.

Trade-offs

  • Author / committer identity: with this change, the GitHub App running the action becomes both the author and committer of the resulting commit. Today the cherry-pick preserves the original author in the author: field. Attribution is recovered via the Co-authored-by: trailer added to the commit message — GitHub renders co-authors with avatars in the commit view.
  • "Partially verified" UI badge: GitHub's UI shows "Partially verified" on signed commits that carry a Co-authored-by: trailer, because the signature attests the API call but not the co-author claim. The API still returns verification.verified: true / reason: valid, which is what branch-protection "Require signed commits" rules check — confirmed by enabling the rule on a sandbox release-1.0 branch and successfully merging a backport PR whose commit had the "Partially verified" badge.
  • File mode changes (e.g. executable bit, type changes between regular file/symlink) are not preserved, because createCommitOnBranch's FileAddition input doesn't accept a mode. Most backports don't touch modes; flagging here for completeness.
  • Renames are flattened into delete-plus-add by passing --no-renames to git diff. The diff content reaching the destination branch is identical, only the visual rendering on GitHub differs.

Tests

  • All existing tests pass.
  • Added backport/backport.test.ts coverage for the new buildFileChanges helper, exercising additions, modifications, and deletions against a temp git repo.

Compiled JS

backport/backport.js is regenerated and committed alongside the TS, matching the repo's convention.

Test plan

Tested end-to-end in https://github.com/pracucci/backport-test (sandbox, no secrets):

  1. Sandbox set up with GHA PR creation enabled (otherwise github.pulls.create() fails with not permitted to create or approve pull requests): gh api repos/pracucci/backport-test/actions/permissions/workflow -X PUT -F can_approve_pull_request_reviews=true -F default_workflow_permissions=write.
  2. Caller workflow added at .github/workflows/backport.yml triggered on pull_request: [closed, labeled]; checks out this PR's branch at ref ce15900ea5af59b4af848d268b33438c0693f10b into ./actions, installs deps via corepack enable && yarn install --immutable (the project enforces engine-strict=true against npm), then runs ./actions/backport with token: ${{ secrets.GITHUB_TOKEN }}.
  3. release-1.0 branch created off main as the backport target.
  4. Test PR Fix changelog branch name typo #7 opened (feature-7 → main) adding a new file docs/feature-7.md — new files avoid the cherry-pick conflicts you get from modifying files that have diverged.
  5. Both labels applied via REST (backport release-1.0 + type/bug) using gh api .../issues/7/labels --method POST --input -. The action gates on both: a backport <branch> label AND a category label (type/bug / type/docs / type/ci / product-approved). gh pr edit --add-label currently silently no-ops due to a Projects-classic GraphQL deprecation error in the CLI.
  6. PR merged with gh pr merge 7 --squash, firing closed with merged: true → action ran.
  7. Backport commit verified at pracucci/backport-test@23eb752bc2 on backport-7-to-release-1.0: verification.verified: true, reason: valid; committer GitHub, author github-actions[bot]; message Add Feature 7 doc (#7)\n\n(cherry picked from commit 9f00f5a6e3…)\n\nCo-authored-by: Marco Pracucci <marco@grafana.com> — cherry-pick attribution + Co-authored-by trailer both intact; only docs/feature-7.md modified.
  8. Backport PR verified at [release-1.0] Add Feature 7 doc pracucci/backport-test#8 ([release-1.0] Add Feature 7 doc) with the original PR's type/bug label plus backport carried over.
  9. Branch protection compatibility verified: enabled "Require signed commits" on release-1.0 and merged PR Github action for signing plugins  #8 — succeeded, so a UI-"Partially verified" backport commit is accepted by the rule (it's verification.verified: true at the API level).

Findings during testing

  • npm install fails with EBADENGINE.npmrc has engine-strict=true and package.json declares engines.npm: please-use-yarn. Consumers pinning to this branch (or anything newer than the npm-blocker) must switch their caller workflow from npm install to corepack enable && yarn install --immutable.
  • First cherry-pick attempt conflicted because the test PR modified README.md which had diverged on main. Use new files for backport tests; same caveat applies to any cherry-pick on a divergent branch.
  • First fully-API-driven run failed at github.pulls.create() with not permitted to create or approve pull requests (a repo-level setting). The branch + Verified commit were created successfully before the failure (pracucci/backport-test@90785ef76d), so the signing-related code paths in this PR were already exercised. Enabling the permission setting let the next run complete end-to-end.

Replace the unsigned 'git push --set-upstream origin <head>' with a
createCommitOnBranch GraphQL mutation. The cherry-pick is still done
locally (preserving the conflict-resolution logic and the betterer
fallback), but the resulting commit is published to the new branch via
the API so it is signed by GitHub's web-flow key (shown as Verified).

Trade-off: the API uses the authenticated identity (typically the
GitHub App running the action) as the commit author and committer.
The original commit's author is preserved via a Co-authored-by trailer
appended to the commit message, so attribution survives.

Limitations:
- File mode changes (e.g. executable bit) are not preserved because
  createCommitOnBranch's FileAddition does not accept a mode. Most
  backports don't touch modes, so this is rarely an issue in practice.
- Renames are flattened into delete-plus-add (we pass --no-renames to
  git diff).
@pracucci

Copy link
Copy Markdown
Author

I will keep this PR open in case anyone using this action needs signed commits (Mimir repository migrated to https://github.com/grafana/grafana-github-actions-go in the meanwhile).

The reason why I'm not merging it is because since Mimir migrated to grafana-github-actions-go I will not do a real end-to-end test for this action, and so I will not have 100% confidence these changes don't introduce any regression.

I leave to anyone relying on this action to merge (or ask to merge) and then take ownership to test it end-to-end.

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.

2 participants