Signed Dependency Updates with Renovate and Gitea
Keeping dependencies up to date is one of those chores that is easy to neglect and expensive to ignore. Renovate solves it nicely: it scans my repositories, opens pull requests for outdated dependencies, and - if I let it - merges them on its own. On a self-hosted Gitea instance with branch protection, however, there is a catch. If I require signed commits on my protected branches, an unsigned bot commit is simply rejected. So before automating anything, Renovate needs an identity and a signing key, and its commits need to show up as Verified.
This post documents the setup I settled on, in both shapes I run it: as a Gitea Action, and as a Kubernetes CronJob. This post will focus on the signing of commits and not on the general setup of renovate bot.
The idea
A few considerations up front:
- The bot’s commits must be cryptographically signed and verify against its Gitea account.
- The exact same signing setup should work whether Renovate runs as a Gitea Action or as a Kubernetes job - only the delivery mechanism changes.
- SSH signing over GPG. Git has supported it since 2.34, Gitea verifies it, and it spares me the GnuPG keyring and passphrase dance in a headless environment.
- Secrets never live in a manifest or a repository.
The signing key
A dedicated key, no passphrase, since Renovate runs unattended and cannot type one:
| |
This yields renovate_signing (the private key Renovate signs with) and renovate_signing.pub (the public half Gitea verifies against).
Gitea side
Everything has to point at one account - the bot account Renovate authenticates as. Three things on that account:
- Email. Add
renovate@example.comunder Settings → Account. This is the address Renovate commits as, and Gitea only marks a signature Verified if the commit email is registered on the account that owns the key. - Signing key. Add the contents of
renovate_signing.pubunder Settings → SSH/GPG Keys and run the verification step Gitea offers. - Token. Generate an API token under Settings → Applications. Renovate uses it to read repositories and open pull requests.
Note: this signs the commits Renovate authors. What ends up on the protected branch when a pull request is merged is a separate question - and the answer turns out to be simpler than installing a second key. I come back to it under Signing the merge.
Renovate configuration
The bot configuration is identical for both deployments. The signing key and token arrive as environment variables; only the identity lives in the config:
| |
When RENOVATE_GIT_PRIVATE_KEY is set, Renovate detects the SSH key and configures git signing for itself - locally in each cloned repository, not globally - so no further git plumbing is needed. The one thing that must line up is gitAuthor: its email has to match the account from step one, or the commits sign but never verify.
Option A: Gitea Action
The lightweight option, living entirely inside Gitea. The key and token come from repository or organisation Actions secrets:
| |
Paste the private key into the secret in raw form, with its real line breaks - not base64. Mangled newlines are the single most common reason a key is accepted but nothing ends up signed; if Renovate ever logs the key in clear text instead of redacting it, that is the symptom. Pipe the file straight to your clipboard (pbcopy < renovate_signing, wl-copy, and so on) rather than copying it out of a terminal, and confirm it is intact first with ssh-keygen -y -f renovate_signing.
Option B: Kubernetes CronJob
For more than a handful of repositories I prefer running Renovate as a CronJob. It keeps the bot off the Gitea host, and - more importantly - it lets me mount a persistent cache so each run reuses the package and per-repository extraction cache instead of rebuilding from scratch and hitting every registry again.
The interesting parts of the job:
| |
The config above adds cacheDir: '/cache' and repositoryCache: 'enabled' so the mounted volume is actually used. The Secret holding the token and key is created out of band - I create it straight from the key file so the newlines survive, and seal it before it ever touches Git:
| |
LOG_FORMAT=json gives me structured logs to ship into Loki, but for day-to-day operation the nicest view is the Dependency Dashboard - one tracking issue per repository, listing every pending update, which the config enables with dependencyDashboard: true.
Requiring signed commits
None of this is enforced until I tell Gitea to require it. The switch lives in branch protection, per repository: Settings → Branches → Add new rule (repository admin rights needed). Set the branch name pattern - main, or a glob like * to cover every branch including ones created later - and enable Require signed commits. Save the rule, and it applies immediately to all matching branches.
With that on, the check runs in the server-side pre-receive hook before a push is accepted, and it rejects any commit that is unsigned or whose signature Gitea cannot verify. It applies on every path - HTTP and SSH, the web editor, the API, and background jobs like auto-merge - so nothing quietly slips past it.
Verify is the operative word: a cryptographically valid signature is not enough on its own, the key has to be one Gitea trusts. That is governed by the Signature Trust Model under Settings → Repository; the default suits this setup, because the bot’s public key lives on its own account and its commit email matches it. It is also why a misconfigured gitAuthor shows up as Unverified rather than being rejected outright - the signature is genuine, Gitea simply cannot tie it to an account.
One consequence is worth stating plainly, because it leads straight into the next section: once this rule is active, any merge style that has Gitea generate its own commit is blocked, since that commit is unsigned and Gitea has no key to sign it with. That is exactly the corner the fast-forward-only approach below sidesteps.
Signing the merge
Signing the commits Renovate writes is only half the story. With branch protection set to require signed commits, what matters is what lands on the protected branch once a pull request is merged.
If Gitea creates a merge commit - the default style - that commit is generated by the server, not by Renovate, and arrives unsigned unless I also install a signing key for the Gitea instance itself. That is possible, through the [repository.signing] section of app.ini, but it means a second private key to store and protect on the server. I would rather not.
There is a cleaner path: set the repository merge style to fast-forward only. A fast-forward creates no new commit at all - it moves the branch reference to Renovate’s existing, already-signed commit. The commit hash never changes, so the signature is preserved untouched, and there is no server-side commit to sign. No second key, and the verified commit shows up in a linear history exactly as authored.
The one requirement is that the branch be a direct descendant of its target at merge time. Renovate takes care of this on its own: it keeps its branches rebased on the base branch and re-signs them as it goes, so by the time a merge happens there is nothing left to replay. A plain rebase merge would also give a linear history, but it rewrites the commits onto the new base - new hashes, and with them the original signatures are gone. Fast-forward only is the variant that keeps the signature intact, which is why I lock the repositories to it.
Addendum: Gitea’s merge-time key check
Gitea runs a signing check on its web merge regardless of the merge strategy, so even a fast-forward - which creates no commit and therefore needs no signature - can be refused with there is no key available to sign this commit once require signed commits is enabled. The keyless approach above is sound at the git level; this is Gitea’s merge path being stricter than it needs to be.
There are two ways through it. The pre-receive hook only checks that incoming commits are signed, so pushing the fast-forward from the command line goes through cleanly - which is also why Renovate’s branch automerge, pushing directly rather than through the merge API, never trips over it. To merge through the Gitea UI under this rule, the alternative is to give the instance its own signing key via the [repository.signing] section of app.ini. Either keeps the history fully signed; the choice is just whether you merge by push or by button.
stat /posts/2026-05-26-sign-renovate-commits/
2026-05-26: Initial publication of the article2026-05-28: Added a closing note on Gitea's merge-time signing-key check