You self-host Forgejo and push-mirror your repos out to GitHub for visibility. External users land on the GitHub mirror and file issues there. Those issues never make it back into your forge.
forgesync closes that loop. It reads the push-mirror config from each repo in your Forgejo, polls every mirror target, and writes new issues, comments, edits, and labels back into the source-of-truth Forgejo. Labels are matched by name, and any the destination doesn't have yet are created. forgesync only ever removes labels it set itself, so labels added on the other side stay put.
forgesync is stateless: every issue and comment it creates carries an HTML-comment marker in the body, and that marker is the only state.
go install git.erwanleboucher.dev/eleboucher/forgesync/cmd/forgesync@latestOr build from source:
make build # binary at ./bin/forgesyncforgesync reads env vars first; the YAML file is optional and only needed for things env vars can't easily express (the per-host map of Forgejo targets).
export FORGESYNC_SOURCE_URL=https://forgejo.example.com
export FORGESYNC_SOURCE_TOKEN=... # PAT with repo + write:issue access
export FORGESYNC_BOT_USERNAME=forgesync-bot
export FORGESYNC_GITHUB_TOKEN=... # PAT with repo:read for mirrored reposOr a YAML file:
cp configs/forgesync.example.yaml configs/forgesync.yaml| Var | Default | Required |
|---|---|---|
FORGESYNC_SOURCE_URL |
-- | yes |
FORGESYNC_SOURCE_TOKEN |
-- | yes |
FORGESYNC_BOT_USERNAME |
-- | yes |
FORGESYNC_GITHUB_TOKEN |
-- | only if any push mirror points at github.com |
FORGESYNC_FORGEJO_TOKEN_<HOST> |
-- | one per non-github mirror host (see below) |
FORGESYNC_POLL_INTERVAL |
5m |
no |
FORGESYNC_INITIAL_BACKFILL |
1h |
no (look-back on the first tick, and the furthest a failing flow catches up) |
FORGESYNC_TICK_TIMEOUT |
0 |
no (0 = no per-tick deadline) |
FORGESYNC_HEALTH_LISTEN |
:8080 |
no |
FORGESYNC_LOG_FORMAT |
text |
no (text or json) |
FORGESYNC_LOG_LEVEL |
info |
no |
forgesync runLooks for configs/forgesync.yaml by default. Override with -c or FORGESYNC_CONFIG.
forgesync auto-derives each Forgejo target from the mirror's URL. The only thing it needs from you is the per-host token, set via an env var named after the host:
# codeberg.org → FORGESYNC_FORGEJO_TOKEN_CODEBERG_ORG
# git.example.com → FORGESYNC_FORGEJO_TOKEN_GIT_EXAMPLE_COM
export FORGESYNC_FORGEJO_TOKEN_CODEBERG_ORG=...Rule: uppercase, with non-alphanumerics replaced by _. Add as many as you have mirror hosts.
docker compose up -dThe image is distroless, runs as non-root, and exposes :8080 for /healthz. The container's own HEALTHCHECK uses forgesync healthcheck against localhost.
No setup beyond the source token. Each tick:
- enumerate repos via
GET /repos/searchon your Forgejo, - for each repo the token is an admin of,
GET /repos/{owner}/{repo}/push_mirrors(listing push mirrors needs admin, so other repos are skipped), - classify each mirror's
remote_address(github.com, or a host listed undertargets.forgejo), - pull issues + comments from the target since
now - 2*pollInterval, or since the last successful run of that repo/mirror/direction if failures have held it back (at mostinitialBackfill), - for each item, search the destination for the marker -- create if missing, PATCH if changed, skip if equal.
Comment /sync on a [PR #N] shadow issue in your canonical Forgejo to promote it to a real Forgejo PR. forgesync will fetch the GitHub PR's head ref, push it to your Forgejo as forgesync/pr-N, and open a PR against the original base.
Fork PRs work. Same-repo PRs opened directly on GitHub will be closed.
Forgejo's push-mirror uses git push --mirror, which deletes refs on the remote that don't exist locally. If you opened a PR on the GitHub mirror from a branch that was never pushed through your canonical Forgejo, that branch only lives on GitHub. Creating forgesync/pr-N on Forgejo triggers push-mirror, which deletes the branch and GitHub closes the PR.
Fork PRs are not affected — the head branch lives in the contributor's fork, which push-mirror doesn't touch.
If a GitHub mirror target is itself a fork, forgesync also copies the pull requests the fork's owner opened on the parent repo into your canonical Forgejo, as [upstream PR #N] issues. They carry the PR's comments and follow its open/closed state, so you can track upstream reviews from Forgejo.
These are read-only. Comments you add to them stay on Forgejo; reply on GitHub to reach the upstream maintainers. Line-level review comments are not copied, only the PR's conversation.
Label an issue local-only (any case) in your canonical Forgejo and forgesync won't copy it, or its comments, to any mirror. Add the label before the next tick: an issue that has already been copied keeps its mirror copy, which simply stops receiving updates. Imported issues are unaffected, so replies to them still flow back to the mirror.