Follow-up to #487: the owner, organization and Activity Log name filters share one compareNames (an Intl.Collator built once), so the Activity Log filter no longer puts capitalized names first.
The feed parsed job statuses with the repository status enum, which has no "warning", so one release filter warning turned GET /api/activities into a 500. It now parses with activityStatusEnum. Adds a route test with warning and mirrored rows.
A global Remove old release assets switch (PRUNE_OLD_RELEASE_ASSETS) deletes the attachments of mirrored releases ranked past each repository's asset limit on every sync. Releases, notes and tags stay. Off by default. A release whose attachments cannot be listed is left alone, and releases the listing already shows without attachments are skipped.
Closes#485
Owner and organization filter options are sorted with Intl.Collator, case and accent insensitive, instead of a plain sort that put "Zebra" before "apple".
- Dashboard, Repositories, Organizations, Storage and Activity Log keep what they last showed in this tab and refresh it in the background instead of starting from a skeleton. The cache is in memory and per user; nothing goes to browser storage.
- The Repositories page renders only the layout on screen, and the mobile cards and Storage rows are virtualized against the page scroll. With 3,007 repositories the page went from 124,174 elements to 880.
- The desktop table rows live in their own memoized component that owns the virtualizer, so a scroll renders only the rows coming into view. MarqueeText no longer forces a style recalculation on mount. With the CPU slowed 4x a scroll frame went from 30.7 ms to 12.6 ms.
- Refresh sizes measures every mirror on the destination now instead of at its next sync. It only reads the destination: one repository listing per owner, the single repository GET for a mirror the listing does not have, and the release listing of repositories that mirror releases. POST /api/storage/refresh runs it in the background; GET /api/storage reports its progress.
- Destination release listings stopped after 30 releases: Gitea and Forgejo ignore per_page and answer 30 a page. The retention pass, the release filter preview and the refresh now share one pager that asks with limit and ends at X-Total-Count.
The archived row's button says Sync like every other row, with a tooltip that automatic syncs are off for archived mirrors and the button syncs it now. Settings text, README and env docs point to Sync.
- Raise the zod floor to 4.6.7 (GHSA-mrhx-c7xc-m3w5). Astro's nested 4.5.4 resolves to the same copy and an override keeps both there. No other package in bun.lock changes.
- Add .github/dependabot.yml: bun for the root, npm for www, open-pull-requests-limit 0 so only security updates open pull requests.
- Record each mirror's destination size (Git plus LFS) and its release asset bytes and count during sync, from responses the sync already fetches, so no new requests. Four nullable columns on repositories, migration 0021.
- GET /api/storage and a Storage page with cards, bars by destination owner and for the largest repositories, and a sortable table with search, an owner filter and a release limit hint.
- Run sources.test.ts in an isolated child process so leaked @/lib/sources mocks cannot reach it.
Closes#479
Co-authored-by: Florian Zevedei <florian.dahn@fdsoftware.de>
- Read only API keys: an Access choice when creating a key, stored in
the key metadata; the middleware refuses writes and most auth calls
for them with 403 API_KEY_READ_ONLY.
- POST /api/mcp: stateless MCP endpoint with 8 tools that call the REST
API over loopback with the caller's key. Needs x-api-key, a cookie
alone gets 401. MCP_INTERNAL_URL overrides the loopback address.
- mcp/: gitea-mirror-mcp, a zero dependency stdio bridge to /api/mcp for
Claude Desktop and Cursor, with a CI workflow that publishes it on an
mcp-v* tag.
- e2e spec for keys, the endpoint and the bridge; docs for the endpoint,
the bridge and read only keys.
The Spline scene kept the GPU at about 45% on an M1 Pro for the whole
visit, even after scrolling past the hero, and phones downloaded and ran
it for a hidden canvas.
- Load Spline only at md and up, after the page loads, and not at all
with reduced motion
- Pause the scene and the shader while the hero is off screen
- Render Spline at 1.5x instead of the device ratio and the shader at
half resolution
- Size the static logo to match the 3D mascot so the swap does not jump
- Drop backdrop blurs on the feature cards and CTA that changed no
visible pixels
- Make the header scroll listener passive
Measured on the built site: GPU use went from about 44% to 28% at the
top and from 42% to 5% below the hero, and the phone page weight went
from 1.19 MB to 0.33 MB.
- JSON 404 for an /api path or method without an endpoint, instead of
the HTML page.
- The 409 from sync/repository and sync/organization carries the stored
record.
- role is optional on sync/organization (default member, force keeps the
stored role, unknown role is 400); the not-found message mentions user
accounts.
- GET /api/repositories/:id and GET /api/organizations/:id.
- API docs: organization endpoints, host and path for pasted URLs, and
the Content-Type header every write needs.
- astro 7.3.2 to 7.3.5 in the app and the site for GHSA-mpr7-6m5w-76rw,
with @astrojs/node 11.1.6.
- A vite ~8.1.6 override in the app: its bun.lock still had vite 8.0.0
and 8.1.5, inside the October vite advisories.
- git-lfs is built with Go 1.27.3, golang.org/x/net 0.61.0 and
golang.org/x/crypto 0.58.0.
- Raise the floors for sharp, smol-toml, source-map-js and
http-cache-semantics in the app and the site, and pin the site's vite to
8.1.6.
- The VEX product for the published image is now
pkg:docker/raylabshq/gitea-mirror?repository_url=ghcr.io. The old
pkg:docker/ghcr.io/... form never matched, so the zlib CVE-2026-85091
exception was ignored on main scans.
Adds a Pre-releases switch (stable only), include and exclude tag patterns (globs or /regex/) with a preview of what a save keeps or removes, per organization and per repository overrides, and RELEASE_TYPE, RELEASE_INCLUDE_TAGS and RELEASE_EXCLUDE_TAGS. Releases in the destination that stop matching are removed on the next sync, and a warning is raised when that empties a destination.
Adds notifyOnNewOwnRepo, notifyOnNewOrgRepo and notifyOnNewStarredRepo under the existing new repository switch. They default to on, so saved configs keep their current behavior without a migration.
Adds GitHub App installation as an authentication option for GitHub sources, with installation token refresh for Gitea 1.27+ pull mirrors, credential redaction, and the account filters applied to installation listings.
Tailwind v4 detects sources automatically and reads every file under the
project root that is not ignored by git. The app writes repository
backups to data/repo-backups by default, and that directory sits inside
the project root on source installs such as the Proxmox script. With
large git bundles there, the build read them into memory and the
process was OOM-killed at the server entrypoints step.
Limit the Tailwind source scan to src/, which is the only place the app
uses utility classes. This also stops the docs site under www/ from
contributing classes to the app stylesheet. Ignore data/ as a whole in
git, keeping its README.
Verified in an unprivileged LXC with a 2 GiB limit: with a 3 GB file
under data/repo-backups the build was killed, and with this change it
completes. Every class dropped from the stylesheet came from www/,
docs or workflow files, none from src/.
* Enable grouped new repo notifications
Add support for the "New repository discovered" notification, including UI/docs updates and tests. Discovery and scheduler imports now send one aggregated alert per owner or organization with capped repo lists, and notification config loading/decryption was refactored so background sends reuse the same safer logic.
* Group starred repository notifications
Send one notification for all newly imported starred repositories, regardless of owner. Generalize discovery notifications to describe imports grouped by owner or as a single starred group.
On Bun 1.4 the node adapter's reader cancel closes the stream before the
request abort fires, so the abort listener's controller.close() threw
ERR_INVALID_STATE as an uncaught exception and the process shut itself
down whenever a dashboard tab disconnected. Both SSE routes now guard the
close and clear their timers on cancel too.
A shutdown forced by an uncaught exception or unhandled rejection now
exits with status 1 instead of 0, so a unit with Restart=on-failure
(the Proxmox community script) starts the service again.
* fix: assign mirrored issues and pull requests to their milestones (#460)
Milestones were created in Gitea but issues never pointed at them: the
issue and PR payloads had no milestone field, and milestones were
mirrored after issues anyway.
Milestones now sync first and return a title to Gitea id map, which the
issues and pull requests passes use to set the milestone on create and
to set, change or clear it on update. The edit only carries a milestone
when it differs, so Gitea writes no repeat timeline entries. Unchanged
items are fixed on the weekly full pass from the listing alone. When
milestones are not mirrored, or one is missing in Gitea, the issue is
left as it is.
* fix: review follow-ups for milestone assignment (#460)
- Never clear a milestone made in Gitea alone. The milestones pass now
returns only the milestones that mirror a GitHub one, and an issue is
only cleared when it sits on one of those.
- Match existing pull request issues by a [PR #N] prefix, so a mirrored
issue that mentions [PR #N] in its title is not taken for that PR.
- The milestone edit on an unchanged pull request is best effort: a
failure is logged instead of retried, and the pass still completes.
- Record a milestone edit on the cached Gitea issue so a retry does not
send it again.
- Store 0 instead of undefined when a created milestone has no id.
* fix: keep mirrored milestones in line with GitHub (#460 follow-up)
Milestones were only ever created, so closing, reopening, moving the due
date or editing the description on GitHub never reached Gitea. The
milestones pass now edits an already mirrored milestone when its state,
due date or description differs.
Due dates compare as calendar days. Gitea moves an edited deadline to
the end of that day in its own time zone, so comparing instants would
edit the milestone on every sync. A removed due date is cleared with
Gitea's year 9999 value, since its edit endpoint ignores an empty one.
The test failed in CI because another file replaces bun:sqlite with
mock.module, which leaks across files depending on order. It now runs in
an isolated child process like the other fetch and sqlite driven suites.
Each case builds its database with manage-db init, so the tables come
from drizzle/ instead of hand written DDL that could drift, and the
bundle is built once in beforeAll instead of at import time.
The Dockerfile rewrite of the manage-db scripts is now a one line sed.
Mirror job timestamps are stored to the second. A sync that starts and
finishes in the same second writes its "syncing" and "synced" events
with the same timestamp, and SQLite returned the tie oldest first, so
the summary took the start event as the repository's current state.
The activities and dashboard queries break ties on rowid, newest first,
and the summary counter prefers a finish over a start from the same
second whatever order they arrive in.
The site moved to pnpm in July 2025 and pnpm-lock.yaml is the lockfile
kept up to date, but www/bun.lock was left behind and still pins Astro 5.
The Cloudflare Pages build image now installs with bun when it finds a
bun.lock, and bun install --frozen-lockfile stops on the stale file, so
every docs build has failed since.
Remove it, ignore it in www so a local bun install cannot bring it back,
and point the site README and CLAUDE.md at pnpm.
* fix: resume issue and PR passes after a rate limit, cheap full passes (#449)
A pass stopped by the rate limit now saves the numbers it finished with
the repository, and the next run repeats the same pass and skips them
unless they changed since it started. Before, every run started over, so
a repository too large for one hour of budget never completed a pass.
The weekly full pass no longer fetches comments, PR details, commits or
files for items already in the destination and unchanged since the last
completed pass. It corrects title, body, state and labels from the
listing alone.
A sync paused by the rate limit logs one line instead of the error
object with every partial result.
* chore: retrigger docs preview
The v3.37.0 change stopped the scheduler run from holding its lock
through a rate limit, but only the refused request stopped. Every other
call queued in the run still went to GitHub and came back 403, and the
retry helper sent each of them three more times. GitHub counts requests
made while limited toward abuse detection, and a reporter's account was
suspended that way.
Every GitHub client now carries a request wrap that reads the gate
before the network: a reset within two minutes is waited out inside the
request, a longer one fails at once with a RateLimitPausedError, and
only the rate limit probe endpoint is exempt. The retry helper does not
retry a rate limit refusal and stops starting the remaining items,
surfacing a RateLimitedBatchError. The metadata component catches in
both mirror paths rethrow rate limit errors so the repository fails and
the scheduler restores it, and the pull request detail fallback no
longer writes a stripped down issue after a refusal. The secondary rate
limit handler closes the gate for its retry-after window.
A cards/list switch in the toolbar. The list shows one row per
organization with its role, destination, repository counts, last
mirrored time, status, the same actions as the cards and the source
and destination links, so a few dozen organizations fit on one screen.
The choice is stored in localStorage next to the theme and time format
preferences.
The actions dropdown that existed twice in the card is now one shared
component used by the cards and the list, and the status badge, source
resolution and repository breakdown helpers moved to a shared module.
After a complete pass, the issues and pull request passes list only the
items GitHub reports as updated since the previous listing (minus a 10
minute margin), so per-issue comment listing and per-PR detail, commits
and files calls run only for what changed. Pull requests use the issues
endpoint for this since pulls.list has no since filter.
The watermark lives in the repository metadata state as syncCursors, one
per kind, and records when the listing started. It only moves when the
pass finishes with no failed item. A full pass still runs when the last
one is older than 7 days, when the destination has no mirrored issues or
pull requests, and after reset metadata, which clears the state.
Fixes GHSA-p7w3-46pg-mv6h. The 3.38.1 guard covered the connection tests,
OIDC discovery and the notification providers, but a source URL stored
through POST /api/sources, PUT /api/sources/:id or the configuration save
was only checked for its scheme, and the source fetch helper called fetch
directly with the upstream body in the error message. The organization
sync route then forwarded that message when it matched a safe pattern.
- The three routes refuse link local and metadata addresses for source and
destination URLs with the same message the connection tests give.
- sourceFetch goes through safeFetch: refused addresses, plain http pinned
to the checked address, no redirects.
- SourceApiError messages carry the URL and status only; the body stays on
the error and is logged.
- createSecureErrorResponse never forwards SourceApiError or HttpError
messages, whatever words they contain.
* fix(security): guard connection tests, close sign-up and scope SSO providers
Fixes the four privately reported issues GHSA-9m33-xfrc-5jxw,
GHSA-6m23-28hh-gjh2, GHSA-2hpx-83vg-gm45 and GHSA-5pp8-r7f5-6q8p.
- The Gitea and GitHub test-connection routes require a signed-in user.
They were the only non-public API routes without a guard and made a
server side request to any URL in the body.
- Email sign-up is refused server side once an account exists unless
AUTH_ALLOW_SIGNUP=true. Only the signup page redirected before; the
endpoint accepted new accounts from anyone reaching the instance.
- SSO providers are scoped to their owner for list, update and delete,
and the client secret is never returned. An empty secret on update
keeps the stored one, and the form says so.
- HttpError messages carry the status only; the upstream body stays on
the response field for logs and never reaches API clients.
- A narrow outbound guard for user supplied URLs (connection tests, OIDC
discovery, ntfy, Gotify, Apprise, webhook): link local and cloud
metadata addresses are refused, host names are resolved and checked,
and redirects are not followed. Private networks stay allowed since
mirroring to a LAN Gitea is the normal deployment.
* fix(security): pin plain http requests to the checked address
The guard resolved a host name and checked its addresses, but fetch resolved
the name a second time, so a name that changes its answer between the two
lookups could still reach a link local address. Plain http requests now go
to the address the check saw, with the original name in the Host header.
https keeps the name: the certificate is validated against it and the
metadata services speak no TLS.
* feat(releases): mirror releases from Gitea and Forgejo sources (#440)
Release mirroring was GitHub only. The list-and-reconcile loop in gitea.ts
now works on a normalized release shape that a per-source lister fills, so
Gitea, Forgejo and Codeberg repositories mirror their releases and assets
the same way GitHub ones do.
- GiteaSourceProvider gains listReleases(owner, repo, limit), paginating
with limit/page and stopping at x-total-count. listReleases is optional
on the SourceProvider interface; GitLab does not implement it.
- mirrorGitHubReleasesToGitea keeps its signature and its behaviour, with
the "Originally published on GitHub:" header line unchanged byte for
byte so existing mirrored bodies are not PATCHed on the next sync.
Gitea and Forgejo releases get the same shape with their own label.
- mirrorGiteaSourceReleasesToGitea is the new entry point. It honours the
same release limit, asset limit, per-destination lock, tag-exists check,
retention pruning and asset reconciliation.
- reconcileReleaseAssets takes the download Authorization from the caller
instead of always sending the primary config's GitHub token: the GitHub
path sends its token, a Gitea source sends that instance's token, and a
tokenless public source sends no header at all. One host's token can no
longer reach another host.
- mirrorReleases left GITHUB_ONLY_METADATA_KEYS and is gated on the new
sourceKindSupportsReleases instead. Issues, pull requests, labels,
milestones and the metadata master switch stay GitHub only, and GitLab
still has releases forced off.
- The three call sites pick the path: a GitHub source with a client goes
through Octokit, a Gitea source through its own API, anything else logs
and is skipped.
- The release switch and its two limits follow the release-capable check
in the settings UI, and the docs, the site and the capability tables say
what is true now.
* test(releases): run the source aware release suite in an isolated child
Other test files replace the HTTP client and related modules with mock.module,
which is process-wide in bun and depends on file order. On CI the new suite
ran after one of them and every case failed on a mocked destination lookup.
Same harness as gitea-org-mirror-destination.test.ts.
An admin who runs this alone wants to show a team what is being mirrored
without handing out a login (#428). GET /api/repositories/export and
GET /api/organizations/export return the tracked rows as a CSV file, and
an Export CSV button on both pages downloads them.
The file is the table without the internal fields: the ids, the metadata
sync state and the mirror option overrides. Rows are scoped to the signed
in user and ordered by full name and by name. No filters in this pass,
the export is always the whole list.
toCsv in src/lib/utils/csv.ts does the RFC 4180 work: a header row, CRLF
line endings, quoting for commas, quotes and newlines, ISO dates, true
and false for booleans, and empty for null.
Once an organization finished its first mirror the card said
"Successfully mirrored" and the menu only offered Mirror Options, Ignore
and Delete, so there was no way to refresh it by hand. The only options
left were waiting for the scheduler or syncing every repository one at a
time (#429).
Adds POST /api/job/sync-org, which takes one orgId and, in the
background, re-discovers the organization's repositories from its
source, mirrors the ones that were never mirrored and syncs the ones
that were. The organization sits at "mirroring" while the run is going
and settles on "mirrored" or "failed". Repositories another run already
owns, ignored ones and ones on their way out are left alone, and an
ignored or already running organization is refused.
The per-organization part of the scheduler's re-discovery moves to
src/lib/organization-discovery.ts so the route and the scheduler share
the identity rules instead of each having their own copy. The scheduler
does the same work as before.
In the UI the organization menu gains "Sync Organization" at the top for
mirrored and failed organizations, in both the mobile and the desktop
card layout.
A scheduled sync took the module level scheduler lock and held it for the
whole run. When GitHub's primary rate limit ran out, the Octokit throttling
plugin slept for the full window inside the request (the report shows
"Waiting 3263s before retry"), up to three times per repository, so the run
never finished. Every following tick logged "Scheduler is already running,
skipping this cycle" and nothing synced until the container was restarted,
with the repositories that had already failed left as "failed" and needing a
manual retry.
The throttle handler now decides what to do based on how far away the reset
is. Waits up to 120 seconds are slept off inside the request as before.
Anything longer records the reset on a new in-memory gate
(src/lib/rate-limit-gate.ts), publishes the existing rate-limit event with the
reset time and fails the request instead of sleeping.
The scheduler reads that gate before each batch and before each repository, in
the auto-mirror loop, the sync loop and the boot auto-start loop. When the
gate is up it logs once, stops the run and releases the lock as usual, and it
writes nextRun as the reset plus one minute so the next tick resumes at the
reset rather than at the schedule's own next slot. A repository whose attempt
failed only because of the rate limit is put back to the status it had before
the attempt, so it is picked up by the next run instead of waiting for a
manual retry.
Fixes#437
Dependabot alert 70 (GHSA-9rgm-9g3h-6x36): devalue below 5.9.1 can be made
to hang on malformed input. Astro and @astrojs/react pull it in, and both
lockfiles resolved 5.8.1. The override floors in package.json and
www/package.json now ask for 5.9.2 or later; both lockfiles resolve 5.9.4
and bun.nix is regenerated.
The login page always opened on the Email tab, so people who sign in with
SSO had to switch every time.
Two things decide the tab now. AUTH_DEFAULT_METHOD sets the instance
default (email or sso, anything else falls back to email) and is served to
the login page by the new public GET /api/auth/methods, which also reports
which methods are available. On top of that the browser remembers the
method last used, stored in localStorage on a successful email login and
when an SSO sign-in starts.
The tab is resolved once the available methods are known: the remembered
method if it is still available, else the server default if available,
else email, else sso. The tabs are controlled now so a manual switch still
sticks.
useAuthMethods calls the new endpoint instead of the two SSO endpoints.
/api/sso/providers/public is unchanged for anyone else using it.
The Scout action rejects a digest reference with "missing or wrong image
input", which failed the v3.36.2 tag build after the push and skipped the
Helm chart publish. Scan the tag the run pushed: the release tag on a tag
build, edge on a main or scheduled build.
A fresh install started with scheduling enabled on a plain 24 hour
interval counted from first login. The automation card only knows clock
schedules, so it showed the 22:00 placeholder as if it were saved while
the scheduler ran at whatever time the user first signed in. Toggling
the card off and on happened to store the clock schedule and hide the
mismatch (#427). The card also showed the browser timezone whenever UTC
was stored, so a stored 22:00 UTC read as 22:00 local.
New configs start with scheduling off unless SCHEDULE_ENABLED or an
interval variable turns it on, with the same rule as the environment
loader, and the built-in default is the 22:00 clock schedule the card
shows. When a plain interval is stored (SCHEDULE_INTERVAL,
GITEA_MIRROR_INTERVAL or an older version) the card says what runs and
how it got there, and leaves the frequency and start time unset until
the user picks one. The timezone chip shows the stored timezone and
turns into a one-click switch to the browser timezone when they differ.
Ignore Organization only changed the organization row. Its repositories
kept their mirrored status, so the scheduler went on syncing them, and
rediscovery kept importing new repositories for the organization, which
auto-mirror could pick up (#429).
The status route now takes a cascade flag, which the UI always sends.
Ignoring an organization ignores its idle repositories; rows in flight or
being deleted finish on their own and rows already ignored stay that way.
Including it again restores the ones it touched: mirrored when the
repository has been mirrored before, imported otherwise. Rediscovery skips
ignored organizations. The route is covered by a test against the real
schema in an in-memory database.
The README, NIX.md and the production snippet in the deployment guide set
services.gitea-mirror straight after adding the flake input, but the
option only exists once gitea-mirror.nixosModules.default is imported.
Only the quick start snippet showed that (#426). Every snippet that
enables the service now shows the import and where the input goes.
The organization cards hardcoded the Gitea icon, the Gitea button label and
the View on Gitea tooltip, while the repository table already named the
configured destination. A Forgejo user saw Gitea on every card (#430).
Both card layouts now use the destination icon and label, and the tooltip
text follows the destination too.
A merge and the release bump that follows it land on main seconds apart.
Both triggered the Docker workflow and both pushed latest, and on v3.36.1
the older build finished last, so latest carried 3.36.0 until the weekly
rebuild replaced it (#425).
The workflow now runs one build per ref at a time, cancelling superseded
branch builds but never a tag build. The latest tag is only pushed by a
stable release tag build, main builds push edge and the short sha, and
Docker Scout scans the digest the run just pushed instead of whatever
latest pointed at.
The repositories table is unique on (user_id, source_id, normalized_full_name)
so the same owner/name can live on two hosts. Discovery used that key to
decide what was new, so two source rows on one host (an organization pinned
to a public-only github.com source next to a personal github.com token) each
inserted their own row for one repository, and the scheduler mirrored both to
the same destination in the same batch.
A repository is now identified by its host and full name whichever source
lists it: personal discovery, organization re-discovery and the two import
endpoints skip repositories the user already tracks on that host, and each
scheduler pass runs one row per upstream repository, logging the duplicate
rows it skips. Found while investigating #417.
Three things combined to make crash recovery restart live jobs:
- isJobInterrupted treated any job older than two hours as interrupted before
looking at its checkpoint, so a large job that heartbeated every two minutes
was resumed while it was still running. The age rule is gone: a checkpoint
is the job's proof of life, and only a missing or stale one (ten minutes)
marks a job interrupted.
- processWithRetry only checkpointed the item that tripped the interval, so a
resume re-processed up to N-1 finished items. Every completed item is now
recorded; the interval only throttles the published progress event.
- updateMirrorJobProgress was a read-modify-write of completedItemIds with no
lock, so two items finishing in the same tick could drop each other. Writes
are serialized per job with the keyed lock from #419.
A resumed job also records a fresh start time. Found while investigating #417.
GHSA-7w5x-hrqm-74c2: smol-toml 1.7.0 and below can be made to hang on a
malformed TOML document. Dependabot only flags the site lockfile (1.7.0);
the app lockfile had 1.6.0 through astro and is raised the same way.
Astro 7.3 moved the startup logger from app.pipeline.getLogger() to
app.getLogger(). The 3.35.1 security bump raised astro to 7.3.2 but left the
node adapter at 11.0.2, which still reads app.pipeline, so running
dist/server/entry.mjs directly crashes with "undefined is not an object
(evaluating 'app.pipeline.getLogger')" on every Bun version. Docker is not
affected because scripts/runtime-server.ts disables the adapter autostart;
the Nix package and bare metal systemd installs start entry.mjs and are.
Fixes#420
Gitea and Forgejo accept any number of attachments with the same name:
CreateReleaseAttachment has no name-collision check, so every upload POST
appends another attachment. Asset reconciliation listed what a release
already had and then kept uploading for tens of seconds or minutes with no
mutual exclusion, so two passes over one repository in the same run both
read "missing" and both uploaded. That wide check-to-write window is what
left two, three or five copies of every asset (#417).
The classifier now groups existing attachments by name, keeps one copy and
reports the surplus and stale ones for deletion. Reconciliation fails
closed: nothing is uploaded when the attachment list cannot be read,
deletes run before any upload, and an asset whose stale copy could not be
deleted is skipped until the next sync. Release reconciliation holds a lock
keyed on the destination repository, so two passes cannot interleave even
when they come from two repository rows. The sync path and both mirror
paths claim the row with one conditional UPDATE instead of reading the
status and writing it in a second statement.
The organization destination editor treated a typed value equal to the
organization's own name as "reset to default" and saved null. That is the
default only under the preserve strategy. Under single-org the default is the
configured destination organization, so an override of "GrapheneOS" on the
organization GrapheneOS was dropped and every repository kept landing in the
destination organization. Three display lines hid it: the custom badge never
showed for such a value, the card read "GrapheneOS -> GrapheneOS" with no
override stored, and the placeholder, helper text and reset label all stated
the preserve default whatever the strategy.
Both destination editors now save what was typed and clear the override only
for empty input, through a shared resolveDestinationSaveValue helper. The
organization editor takes the default for the configured strategy as a prop
and uses it everywhere it used to name the organization, and any stored
destination counts as an override. Reset to Default no longer saves the old
value again, which it did because setEditValue does not reach the handler's
closure.
The repositories list response now carries the organization destination
overrides, so a repository row shows where its organization actually sends it
instead of the strategy's owner, which is why the reporter believed the
repository level was ignored too.
Server side is unchanged: getGiteaRepoOwnerAsync already resolved starred
mode, then the repository override, then the organization override, then the
strategy. Tests cover that order for single-org and the new save rule.
Fixes#416
Every one of these advisories was published on 2026-09-08 between 20:30 and 21:26 UTC, against the versions this repository was already pinning from the earlier rounds. Same package names, new CVEs, not a regression from anything that merged today.
**Application** (the nine Docker Scout alerts on main)
| Package | Was | Now | Advisory |
| --- | --- | --- | --- |
| astro | 7.1.0 | 7.3.2 (floor ^7.2.8) | GHSA-26w7-cxv4-gfx2, remote code execution through AVIF image optimization (critical) |
| @xmldom/xmldom | 0.8.13 | 0.8.15 | eight advisories, CVE-2026-83605 through CVE-2026-83619 (high) |
| sharp | floor ^0.35.0 | ^0.35.4 | GHSA-rgj7-g3m4-5g8c, libheif |
| js-yaml | floor ^4.3.1 | ^4.3.2 | GHSA-2883-xcg3-v3hh |
| svgo | (no floor) | ^4.1.0 | GHSA-w27v-7q3p-w38r, GHSA-4vpr-x523-8j87 |
**Documentation site** (the six Dependabot alerts against `www/pnpm-lock.yaml`)
astro to ^7.2.8 (resolves 7.3.2), and pnpm overrides for sharp ^0.35.4, svgo ^4.1.0 and js-yaml ^4.3.2. That covers all six, including the moderate Astro authorization bypass (GHSA-376h-93r7-7g6f) which 7.2.4 already fixed.
This supersedes the two Dependabot pull requests, which only cover the site and overlap with each other.
Checked: 906 tests pass, `bun run build` passes, and the site builds its 37 pages. The lockfile change is confined to the bumped packages and their transitive resolution.
Follow-up to #409. Public-only accounts could add and mirror an organization, then hit the source-token gates that were left behind everywhere after the first mirror. Each item was reproduced against a running instance with only a destination configured and a tokenless GitHub source, and re-checked after the fix.
**The paths that still demanded a source token**
- `POST /api/job/sync-repo` and `POST /api/job/schedule-sync-repo` answered 400 "Config missing for the user or token", so the Sync button never worked for these accounts. Neither route uses that token; they dispatch to the destination and resolve source clients per repository. Both now gate on the destination token, like the mirror and retry routes already do.
- `recovery.ts` threw "GitHub token not found in configuration" before the per-source resolution right below it, so an interrupted mirror never recovered. The gate is gone and a tokenless GitHub source now gets the same anonymous client the job routes build.
- `mirrorGitRepoIssuesToGitea` and the pull request, label and milestone functions required the config token even though the client arrives as a parameter, so with the anonymous client they threw and the per-component try/catch logged an error on every sync. They now require only the destination, and public metadata mirrors.
**The bulk import**
`POST /api/sync` iterated every source, including tokenless ones whose personal listing 401s, counted them as failed, and answered 502 when there was nothing else. It now skips them the way `importRepositoriesFromSources` does, reports them as `skippedPublicSources`, and says so when every source is public only.
**The organization pin**
`force: true` without a `sourceId` or `provider` re-pinned the organization to the primary source. Reproduced with two sources: an organization pinned to the GitLab source and one deliberately left on every source were both moved onto the primary. Only a request that names a source re-pins now; naming a provider (public mode) still re-pins as #409 intended. Covered by a new route test.
**The badge**
`invalidateConfigCache()` cleared the cache but nothing re-read it, so the Public only badge appeared only after a reload. Mounted `useConfigStatus` hooks now refresh on invalidation, through one shared in-flight request so a page with several of them still makes a single call.
Two things from the review I deliberately did not change: the anonymous 60 requests an hour budget with its long `onRateLimit` waits, and `rediscoverOrganizationRepositories` running for every user rather than only tokenless sources. Both are judgement calls about the feature rather than defects.
Tests: 906 pass. Build passes. Type errors in the touched files are unchanged from main.
The organizations grid used `repeat(auto-fit, minmax(27rem, 1fr))`. `auto-fit` collapses empty tracks, so with a single organization the one card stretched across the whole content area, while two or more sat at half width. `auto-fill` keeps the tracks, so a card is the same width no matter how many there are.
Both the list and the loading skeleton use the same class, so both changed.
Checked in the browser at 820, 1440 and 2000 px wide with one and with four organizations: one card now stays at column width, two columns at 1440, three at 2000, and a single column below the `sm` breakpoint, all unchanged from before for two or more cards.
Public organizations can be mirrored from GitHub, GitLab and Gitea/Forgejo without configuring a source.
- The Add Organization dialog offers a Public only mode (the default when no source is connected): pick a provider, optionally an instance URL, and the organization is imported anonymously.
- A tokenless source row is found or created for that provider and host, and the organization is pinned to it, so attribution, locks, the scheduler and cleanup keep working per source.
- New createPublicGitHubClient: a throttled anonymous Octokit for tokenless GitHub sources, used by the org mirror job, per-repo mirror and retry, the sync metadata client and the source provider. An empty token never reaches createGitHubClient.
- GitHub organization listings degrade to public repositories when the source is tokenless or the token cannot see the private and member listings.
- The scheduler gates on the destination token only, skips personal discovery for tokenless sources, and re-lists pinned organizations each tick so newly published upstream repositories appear.
- The Sources card can save a row with no username and no token and marks it Public only; the organizations and repositories pages work with no configured source.
Thanks to @TomRoyls. (#409)
Follow-up to #407.
The bulk import cleared an organization's pin whenever another connected source listed the same organization name. That also undid pins a user had set on purpose through the card picker or the add dialog: pin acme to GitHub because the GitLab group of the same name is unrelated, and the next Import widened it back to every source.
Now the import only clears pins it created itself in the same run. Organizations it inserts (or re-pins on recovery) are remembered by name for the rest of the run; when a later source lists one of those names, the pin is cleared, since the organization spans several sources, the same case migration 0020 leaves unpinned. Pins that existed before the run are never touched. The bookkeeping is applied after each source's transaction commits, so a source that fails part way leaves it unchanged.
- `selectSameRunPinsToClear` in `src/lib/sources.ts` holds the rule, with unit tests.
- `src/pages/api/sync/index.ts` tracks the run's pins and applies the rule; the existing-org lookup goes back to name to status.
- Changelog line under the per-organization source selection entry.
Tests: 878 pass. Build passes.
Organizations can pin the source they mirror from when more than one source is connected.
- New nullable organizations.source_id column (migration 0020_org_source), backfilled only when an organization's repositories agree on exactly one source.
- NULL means every source; a pin whose source was deleted falls back to the same. A valid pin scopes the org mirror job, the repository count breakdowns and org deletion to that source.
- POST /api/sync/organization persists the pin, PATCH /api/organizations/:id sets or clears it, the bulk import stamps discovered orgs and clears the pin when a second source lists the same org.
- UI, only with more than one source connected: a Source picker in the Add Organization dialog, a per-card inline source editor with an Every source option, and per-org View on source links.
- The org mirror repository query now filters by user.
- Shared provider icon map in src/lib/source-providers/icons.ts.
Review follow-ups after #403 and #404: dialog copy and placeholder, forge-aware owner parser, GitHub only mirror switches available while any connected source is GitHub, configuration header copy, self-contained encryption test. Also builds the Sources card token settings link from validated URL parts (CodeQL alert) and adds a VEX statement for CVE-2026-85091 in zlib, which nothing in the image can reach.
A user can connect several sources (GitHub, GitHub Enterprise, GitLab, Gitea/Forgejo) and every repository remembers which source it came from. New sources table (migration 0019) with repositories.source_id backfilled by provider and host, per-user unique indexes widened to include the source, a Sources card on the Configuration page with per-source lock confirmations, and credentials resolved from each repository's own source in discovery, import, scheduling, organization mirror, retry, recovery, push targets, cleanup and reconcile. The legacy githubConfig connection fields mirror the primary source so scripts and env setups keep working.
The organizations page and the add dialog now follow the configured source: provider label, instance URL, icon and the organization noun (group on GitLab). The billing manager role only appears for GitHub. Adding an organization whose name contains a slash returns 400 with a hint to use the top level group. New tests cover getOrganization and listOrganizationRepositories on the GitLab and Gitea adapters.
Adds a Skip forks tri-state to the organization Mirror options dialog. An organization can opt out of importing and mirroring forks without touching the global switch, and the pin is honored by the bulk organization mirror, the add-organization route, bulk import and the scheduler.
Strip setuid bits and add an OpenVEX exception for the three util-linux CVEs Debian will not fix in trixie, pin golang.org/x/crypto 0.56.0 and x/net 0.58.0 in the git-lfs build, and build git-lfs with Go 1.27.1.
Changing a repository's or an organization's destination used to change a
label and nothing else: the mirror stayed where it was, sync failed once
someone transferred it in Gitea, and Retry created a second copy.
Sync now looks for a mirror of the same source anywhere on the destination
before failing, and the mirror path does the same before creating a copy.
Reconcile reports moved mirrors and can record their new location.
The destination editors offer to transfer the existing mirror on Gitea or
Forgejo, with a confirmation for organizations. Pending transfers, when the
token cannot create repositories under the new owner, are reported, and
sync follows the mirror once accepted. Nothing is deleted on either side.
New use-case articles: mirror GitLab to Gitea, back up Codeberg and Forgejo repositories, mirror to GitHub or GitLab, automate mirrors with the API, adopt existing Gitea mirrors, and control release asset storage. Existing articles and the comparison page updated for the OCI Helm chart, the extra sources and destinations, and reconcile.
Hero copy mentions the GitLab, Gitea and Forgejo sources and the GitHub, GitLab and Forgejo destinations as beta with GitHub to Gitea as the supported default. Nine feature tiles with beta pills for sources and push destinations, five new FAQ entries linking into the docs, host neutral wording in the existing answers, footer links to the new docs pages, and the structured data version brought up to 3.31.0.
Four new documentation pages (sources, destinations, API and API keys, cleanup and reconcile), updates to the configuration, environment variables, quickstart, architecture and advanced pages, refreshed configuration screenshots and new ones for the destination dropdown and the API keys card. Also fixes the path prefix variable name in docs/API.md and the stale follow-up note in docs/SOURCE_PROVIDERS.md.
The GitLab and Gitea / Forgejo sources and the Forgejo, GitHub and GitLab destinations show a beta pill and beta wording; the docs say GitHub to Gitea is the supported path. Testing every source and destination pair against real hosts turned up three bugs, fixed here: the pull mirror path never recorded a row's destination host, reconcile counted rows mirrored to a previous destination as missing, and the first push into a freshly created GitLab project failed because the host reports it as not found for a couple of seconds.
The second overrides key added in #381 shadowed the original block, so JSON parsing kept only the four newer pins and the seven older ones were silently ignored on any re-resolve. One block with all eleven now; the lockfile records them and the resolved version set is unchanged.
GitHub and GitLab as mirror destinations, both beta. Neither has a usable pull mirror API, so the app keeps a bare clone of each source repository and pushes its branches and tags to the target with force and prune. Target adapters create, archive and delete repositories through each host's API. A dispatcher routes mirror, sync, retry, scheduler, recovery and cleanup by destination; Gitea and Forgejo are unchanged. Migration 0018 records each repository's destination host. Metadata, LFS, wiki and force push protection are disabled with a reason for push targets.
Closes#377
The chart pinned appVersion 3.24.0 and nothing bumped it on release, so a default install ran an image six minor versions old, and the docs example pointed at a tag without the v prefix that does not exist on the registry.
The chart version now equals the application version. The tag build's sync job updates Chart.yaml alongside package.json, and a new job in the tag build packages the chart after the image is pushed and publishes it to oci://ghcr.io/raylabshq/charts/gitea-mirror. Docs and the website install tab show the OCI install.
Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
Compare what Gitea or Forgejo holds under every owner this account
mirrors into with the repositories table. Mirrors of the configured
source that have no row are reported as untracked and can be adopted;
rows marked mirrored whose repository is gone are confirmed with a
direct check and can be reset to imported so the next run recreates
them. Native repositories and mirrors of other hosts are listed and
never touched, and nothing is deleted or archived. Exposed as
POST /api/cleanup/reconcile and as a dialog on the Automation tab.
Closes#284
Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
Build git-lfs with Go 1.25.14, stop serving the runner stage from the
layer cache so Debian security updates land, move typescript and
@astrojs/check out of the shipped dependencies, bump the nested copies
of browserslist, js-yaml, nanoid and sharp to patched versions, and
update browserslist in the website lockfile.
Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
* feat(auth): API keys for programmatic access
Adds the @better-auth/api-key plugin so scripts and CI pipelines can call
the existing endpoints with an x-api-key header instead of a session
cookie. Keys are owned by a user, hashed at rest, prefixed gm_, never
expire unless an expiry is chosen, and are not rate limited. A small
guard plugin refuses key management calls that arrive with a key, so a
leaked key cannot mint or revoke keys.
New API Keys section on the Authentication tab with create, show once,
copy and revoke. Migration 0017 adds the api_keys table with the
validator fixture. docs/API.md documents the header and the calls
automation needs. An e2e spec covers create, use, refuse and revoke over
HTTP. bun.nix regenerated for the new package.
Closes#314
Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
* fix(e2e): send Origin on cookie-authenticated key management calls
Better Auth rejects a cookie-authenticated POST without an Origin header (403 MISSING_OR_NULL_ORIGIN). Browsers always send one, the Playwright request context does not, so the spec sets it on the create and delete calls. Also asserts the guard's 403 code and documents the Origin requirement for scripts that manage keys with a session.
Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
Adds a release asset limit next to the release limit. The release limit
still decides how many of the newest releases exist in Gitea at all. The
new asset limit decides how many of those also get their assets uploaded;
older releases inside the release limit are created with their notes and
tag only. Unset means assets for every mirrored release, which is what
happened before, so existing setups do not change. Zero means release
notes only. The limit inherits global to organization to repository
through the existing limit-key machinery, and lowering it never deletes
assets that were already uploaded; it only gates what a sync uploads.
Settings gets a second box next to the release limit, the overrides dialog
gets a matching row for organizations and repositories, and the env loader
reads RELEASE_ASSET_LIMIT. The README explains both limits and gains a
troubleshooting entry for the read-only push error people hit on pull
mirrors.
Closes#311Closes#59
Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
better-auth, @better-auth/oauth-provider and @better-auth/sso move from
1.6.23 to 1.7.2. This is the first stable line with the oauth-provider fix
for unbound resource indicators (Dependabot alert #55); no 1.6.x release
has it.
1.7 keys accounts by (issuer, accountId) and looks the credential account
up with issuer "local:credential" on sign-in, so migration 0016 adds the
column and backfills existing rows: local accounts get local:credential,
SSO accounts take their provider's issuer, other OAuth accounts get the
local:oauth namespace. It also adds jwks.alg and jwks.crv, the 1.7
oauth-provider columns, and the oauth_resources, oauth_client_resources
and oauth_client_assertions tables.
A database that booted the reverted 1.7.0-rc.4 build (on main for a few
minutes on 2026-08-06, and any dev database from then) already has part of
that schema without a 0016 record, which makes 0016 fail on every boot.
repairStrandedBetterAuth17Schema drops the leftovers before migrate runs,
in the same way the #312 repair handles stranded SSO columns; the
migration validator exercises it.
createOAuthClient no longer takes "type"; the SSO applications route sends
application_type (web or native) and reads it back with the legacy column
as fallback. bun.nix is regenerated. reset-users writes the issuer.
Supersedes #374.
Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
Adds a Source dropdown to the configuration card (GitHub, GitLab beta,
Gitea/Forgejo) backed by a source provider interface with three adapters.
Discovery, mirroring, recovery and cleanup go through the provider;
repositories record their source (migration 0015); clone credentials are
built per host and a repository from another host is refused at the
mirror sites. Issues, pull requests, releases and star lists stay GitHub
only. The destination card gets a Gitea/Forgejo dropdown, and both hosts
lock once repositories exist, with an explicit confirmation to change.
Closes#375. Helps #371.
Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
A job that had just started, with in_progress=1 and no checkpoint yet,
matched findInterruptedJobs immediately. Since #297 the middleware runs
that check on every request, so a live job was "resumed" by recovery
while the original process was still working on the same repositories.
The request-level 15 second timeout then released the in-flight latch
without cancelling recovery, and later requests logged misleading
"already in progress" and "completed with some issues" lines.
- Move the liveness rule into interrupted-job-detection.ts, as both a
predicate and the SQL condition used by findInterruptedJobs. A job
with no checkpoint is only interrupted once it is older than the 10
minute checkpoint window (or has no recorded start at all).
- Stamp an initial checkpoint on in-progress jobs at creation.
- Refresh the checkpoint every 2 minutes from processWithResilience so a
single long item cannot make a live job look interrupted. The refresh
only touches rows still in progress.
- Keep the middleware recovery latch held until the recovery promise
settles, not until the request stops waiting, and clear the timeout
timer so it no longer leaves a dangling rejection.
Tests cover the predicate, the SQL against an in-memory SQLite database,
and the wiring into helpers, concurrency, and middleware.
Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
A row spent three stacked lines on a status, a repo name and a Show
Details button, leaving most of its width empty. It now follows the
dashboard's Recent Activity shape - status circle, label, time - and uses
the width for the repository or organization and the message, with the
whole row toggling the details pane. Rows went from about 170px to 69px,
so ten fit where six did.
The status to icon and colour mapping was a chain of ternaries written
out twice, once for mobile and once for desktop; it is now one table,
shared with the new stat chips.
Those chips fill the empty half of the filter row and report the current
state of each repository or organization rather than counting events, so
a repo that failed and then synced counts once, as synced. Each one
filters the log to that status.
Two virtualizer bugs surfaced while measuring the new rows:
- measureElement reads the row index from a data-index attribute that was
never set, so every measurement was dropped and rows only ever used
estimateSize. That is why the old estimates were hand-tuned to the
markup.
- Expanding a row called virtualizer.measure(), which discards every
measurement rather than re-measuring the row that changed.
Search filtering also ran twice, once in ActivityLog and again in
ActivityList over the list it had already filtered. It happens once now,
which is also what lets the toolbar show a count that matches the list.
Also drops the status dot from the dashboard repository rows, which
repeated the status pill beside it without its label.
Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
All three list pages switched from the compact toolbar to the full one at
sm (640px), so a tablet in portrait got the desktop row with six controls
and the search box squeezed down to its icon. The compact layout already
carries every filter in its drawer, so the handover moves to lg (1024px)
and tablets get a usable search box.
Lists also sized themselves off the viewport with hard-coded pixel
offsets - max-h-[calc(100dvh-231px)] and -276px. Those went stale the
moment a toolbar changed height, which is what put a second scrollbar on
the page next to the table's own. Both now flex into the space the
toolbar leaves, so the visible row count follows the window instead of a
fixed guess: the repository table shows 13 rows at 800px and 23 at
1400px, where it used to stop at the same count either way.
Also drops the bottom status bar from both lists. Its count moves up into
the empty left half of the filter row, and its live indicator was saying
what the header's LIVE button already says.
Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
main was min-h-screen, so it could grow past the viewport, while the
content section sized itself with h-[calc(100dvh-4.55rem)] - a guess at
the header's height. Whenever the header came out taller than that
guess, the body scrolled behind the section's own scroll and every page
showed two scrollbars.
The shell is now pinned to the viewport and the section takes whatever
the header actually leaves, so the magic offset is gone and the height
is correct whatever the header does.
Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
Both dialogs asked for the name and owner as separate fields, so adding
something you were looking at on GitHub meant reading the URL and
retyping it in pieces.
Each dialog now has a GitHub URL field above an OR divider; filling it
populates the fields below. Pasting a URL into the name field splits it
as well, rather than dropping the whole URL into one box.
Parsing handles what people actually have on the clipboard: browser
URLs, clone URLs, SSH remotes, the owner/repo shorthand, a bare host,
and deep links into a repo. It drops the .git suffix, tolerates trailing
slashes, query strings and angle-bracket wrapping, and rejects reserved
GitHub paths like /settings so they cannot be read as an account name.
Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
The toolbar carried six controls plus Mirror All and overflowed on
narrower desktop windows, and the owner and organization pickers did not
match the dropdowns beside them.
- Moves the status, mirror options and sort dropdowns down to the row
that carries "Showing N of M" and Clear filters, right-aligned. They
live in RepositoryTable now because that is where the filtered count
is computed, and the row renders in both the loading and loaded states
so the controls do not appear only once data arrives.
- Makes the owner and organization triggers match the Select triggers.
They kept a double-chevron icon and the outline Button styling; the
Select trigger classes are now exported from ui/select so both use one
definition instead of a copied class string. They stay comboboxes
rather than Selects because those lists grow with the repository count
and need the search box.
- Widens both to 200px (w-50), which fits longer owner names.
- Hides the moved dropdowns below sm. The mobile filter sheet already
covers all five filters, so on mobile they were duplicates sitting
loose on the page.
Also fixes hasAnyFilter, which used val?.toString(), yielding undefined
for an unset filter. undefined !== "" is true, so an unfiltered view
reported "Showing 697 of 697 repositories / Clear filters" and a
"Filters applied" footer with nothing filtered.
Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
The two cards sat at whatever height their own content needed, so they
ended at different points whenever the lists were different lengths.
That is the normal state: repositories and mirror jobs come from
independent tables, and activity accumulates on every sync while the
repository list only grows when a repo is added.
- Drops items-start from the row so the columns stretch to the row
height, in both the skeleton and the loaded state so the layout does
not shift on load.
- Adds h-full to both cards. Without it the column stretches but the
card inside still hugs its content, so removing items-start alone
changes nothing on screen.
- Aligns the activity row padding with the repository rows (py-3 to
py-3.5) so the rows line up when the two lists do have equal counts.
Verified in the browser at 8 vs 8 and 8 vs 5 items: both cards measure
657px in each case.
Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
The card used shadcn Card/CardHeader while its neighbour used the plain
settings markup, so the two headers were different heights and their
dividers did not line up. Rebuilt it on the same markup: icon, title,
divider, body, footer.
- Drops the header description, which wrapped under the Add provider
button and collided with it.
- Moves Add provider into the header row. The header uses py-3 rather
than py-4 because the h-8 button makes the row taller than a text-only
one; both headers now measure 56px.
- Removes mr-2 from the Plus icons. The button already applies its own
gap, so mr-2 stacked an extra 8px and pushed the label off-centre.
- Gives the provider detail labels weight and a fixed 96px column.
Everything was one muted weight with no hierarchy, and "Organization:"
overflowed the old 80px column so its value broke the alignment.
Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
Database Maintenance footer said "Last cleanup"/"Next cleanup", which
wrapped onto two lines next to a full timestamp. The card is already
titled Database Maintenance, so "Last run"/"Next run" says the same
thing and fits.
Same for the Smart backup tile: "Snapshot only on history rewrites"
wrapped, and "history" is redundant since a force-push is the only
rewrite the sync sees.
Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
Follow-up to #362 and #365 for the remaining points raised in #361.
- Add `releaseLimit` to the per-repository and per-organization mirror
overrides (repo > org > global > 10), surface it in the Mirror Options
dialog with gating and validation, and pass the resolved value into the
release mirror. Tags need no equivalent: Gitea's git mirror fetches every
ref and they cost nothing beyond the commits already in the clone.
- Reconcile the Gitea description and topics with GitHub on every sync, not
only at migration time. Read both live in one call, write the refreshed
description back to the local row, and skip writes that would not change
anything. Mirrors created before #224 or whose upstream description
changed since now pick it up on the next sync.
- Rework the mobile repository card: name, Custom badge and menu on one
vertically centred row, secondary badges on their own row below, status
and last sync stacked on the right.
Fork PRs (and Dependabot PRs) never receive repository secrets, so the
Docker Hub login ran with empty credentials and Docker Scout then died
with "user githubactions not entitled to use Docker Scout". Every
external contributor got a red docker check they could not do anything
about (#368, #356, #357, #359).
- Gate the Docker Hub login and both Scout PR steps on the PR coming
from this repo, the same guard the login/push/comment steps already use.
- Add a Trivy scan of the locally loaded image on every PR. It needs no
credentials, so forks get real vulnerability feedback. Output goes to
the job summary because GITHUB_TOKEN is read-only on fork PRs.
- Only upload the Scout SARIF when a file exists, so fork PRs stop
logging "Path does not exist: scout-results.sarif".
- Update the workflows README, which still described a docker-scan.yml
that no longer exists.
Not using pull_request_target on purpose: it would run untrusted PR code
with the Docker Hub token available.
Three fixes for the two problems reported in #366:
1. Auto-trust registered SSO identity provider origins. better-auth
1.6.23 (shipped in v3.21.0) added SSRF hardening to the SSO plugin:
sign-in rejects IdP endpoints whose hostnames resolve to private
addresses unless the origin is in trustedOrigins. Homelab split-DNS
setups (IdP domain resolving to a LAN IP from inside the container)
broke on every sign-in with a 400. Registering a provider is an
explicit operator action, so its issuer and endpoint origins are now
added to trusted origins automatically.
2. Surface SSO sign-in errors in the login form. The auth client
resolves with { data, error } instead of throwing, so server-side
rejections were silently swallowed - the button flipped back from
"Redirecting..." with no feedback and nothing in the logs.
3. Honor deleteFromGitea (CLEANUP_DELETE_FROM_GITEA). It was documented
as "Delete repositories from Gitea" defaulting to false, but cleanup
never read it and always archived/deleted orphans on the Gitea side.
It now gates the Gitea-side operation: when disabled (default),
orphans are only marked archived or removed in gitea-mirror's own
database and the Gitea/Forgejo copies stay untouched.
Verified end to end against a live server: sign-in with a private-IP
IdP returns 400 discovery_private_host on v3.27.1 and a 200 with the
authorization URL on this branch; the login form now shows the server
error as a toast; delete cleanup with the flag off removes only the DB
row while the flag on contacts Gitea.
The per-repo overrides from #361 were reachable only from the desktop
repositories table. The mobile card layout had no dropdown at all, and
the organizations page had the inverse gap: the item existed in the
mobile card menu but not the desktop one.
Mobile repo cards get a three-dot menu in the header opening the same
MirrorOverridesDialog, plus the Custom badge shown when a repository
overrides something. The organizations desktop dropdown gains the same
Mirror Options item the mobile variant already had.
Four older articles directed readers to UI elements by names that no
longer exist. Verified against src/components/config/:
"Content & Data" section -> "Mirror Content"
"Mirror metadata" toggle -> "Repository metadata"
"Git LFS (Large File Storage)" -> "Git LFS objects"
"Mirror releases" -> "Releases & tags"
"Preserve structure" strategy -> "Preserve Structure"
The new articles from #363 already used current names or generic
wording, so only the older four needed changes.
* feat(www): generated sitemap, article metadata, and de-orphaned comparison page
The hand-written sitemap listed one URL of 24 and was dated January 2025.
astro.config now sets `site` and uses @astrojs/sitemap, so every built
page lands in sitemap-index.xml on each build. robots.txt points at the
new index and drops the duplicate user-agent block.
Use-case layouts gain the OG/Twitter tags the docs layout already had,
and all three article layouts now emit TechArticle JSON-LD.
Nothing on the site linked to /comparison/github-backup-tools/. It now
has a header nav item, a footer link, and a homepage CTA next to the
use-cases button. The footer Documentation link points at the site docs
instead of the GitHub tree, and footer internal links no longer open in
new tabs.
* feat(www): five new articles and an interlinking pass
New guides, each grounded in shipped behavior:
- Migrate from GitHub to Gitea (parallel-run migration playbook)
- Mirror Git LFS repositories (why one bad LFS repo fails its whole
migration, and the per-repo override from v3.27.0 that fixes it)
- GitHub API rate limits (ETag conditional requests making 304s free)
- Mirror issues and pull requests (what transfers, the PR-to-issue
conversion, why processing is sequential)
- GitHub backup on Unraid (based on the RayLabsHQ CA template)
All five are registered in the use-cases index; appended after the
existing entries so the homepage's featured three stay unchanged.
Interlinking pass across the existing articles: contextual inline
links where the text already referenced a topic, plus a short related
guides section per article. The comparison page's next steps now cover
the new guides, and the docs quickstart bridges to the use-case section.
Every article now carries 4 to 9 internal links; a build-time check
found 0 broken links across 440.
* feat: per-repository and per-organization mirror option overrides
Closes the gap reported in #361: a repository whose LFS fetch fails could
not be mirrored at all, because LFS was a single global switch. Gitea runs
the LFS fetch inside its own migration, so a failure aborts the whole
migration with nothing to salvage. The only fix available to us is to stop
asking for LFS on that repository.
Mirror options now resolve across three tiers, per flag, most specific
first: repository override, then organization override, then the global
config. NULL at a tier means inherit, so overriding LFS on one repo leaves
its other options alone.
Adds resolveMirrorOptions(), which replaces 24 scattered reads of
config.giteaConfig.* across three near-identical blocks: the two mirror
paths in gitea.ts and the sync path in gitea-enhanced.ts. The third was
easy to miss and mattered: without it, overrides would have been honored
on first mirror and silently ignored on every scheduled sync afterwards.
The starredCodeOnly clamp is folded into the resolver and deliberately
outranks explicit overrides, preserving the existing behavior that starred
repos mirror code only.
Editing is on the objects themselves, via the existing three-dot menus on
the Repositories and Organizations pages, not in Configuration, which keeps
holding the global defaults. A repositories filter and a row badge make it
possible to find which repos deviate.
Malformed override JSON degrades to "inherit" rather than throwing, so a
bad value can never break a mirror run.
* fix(ui): disable mirror toggles that cannot take effect, and explain why
A toggle the runtime will ignore should not look editable. Three cases
were doing exactly that, so they now share one mechanism:
getMirrorOverrideGating() returns a reason string per flag, and the dialog
disables the control and prints that reason underneath.
Starred clamp. When a repo is starred and starredCodeOnly is set, the
resolver forces every metadata flag off regardless of the override. The
dialog previously let the user set them anyway. The clamped set now lives
in STARRED_CLAMPED_KEYS, shared by the resolver and the gating helper so
the flags the UI disables cannot drift from the ones the runtime clamps.
LFS is deliberately excluded from that set, since turning LFS off per
repository is the point of #361, and a test pins that.
Labels. shouldMirrorLabels is `mirrorLabels && !mirrorIssues` in both
mirror paths, so labels cannot take effect while issues are mirrored. That
gate reacts live to the in-progress edit rather than only the saved state.
Inherit hint. For a repository the hint now reflects global -> org instead
of global alone, via a new name-keyed GET
/api/organizations/mirror-overrides that reuses
loadOrganizationMirrorOverrides. Personal repos skip the fetch, a failed
fetch degrades to the global values rather than blocking the dialog, and
the hint is suppressed while in flight so it is never briefly wrong.
Widens the UI to mirrorLabels and mirrorMilestones. mirrorMetadata stays
out: no mirror path reads it, so a per-object override would resolve
correctly and then do nothing. See the report for that finding.
useGiteaConfig additionally returns advancedOptions so callers can read
starredCodeOnly without a second request.
* fix(ui): read inherited mirror flags from mirrorOptions, not giteaConfig
/api/config does not return the mirror flags on giteaConfig. On the way
out, mapDbToUiConfig reshapes them into a separate mirrorOptions object
using different names (mirrorLFS, not lfs) and nesting the metadata flags
under metadataComponents with short names (issues, not mirrorIssues).
The dialog was written against the DB shape, so every lookup returned
undefined. Coerced to false, that is indistinguishable from a real "off",
which produced two symptoms: every inherit hint read "currently off"
regardless of the actual global config, and the labels gate never fired
because the effective issues value it keys on came from the same dead
source.
Adds mirrorOptionsToFlags() as the single conversion point, matching the
derivation in mapUiToDbConfig exactly, including that mirrorMetadata is a
master switch over the metadata components while lfs and mirrorReleases
sit outside it. Both dialogs now go through it, and useGiteaConfig returns
mirrorOptions alongside advancedOptions.
Server-side mirroring was never affected: the resolver reads config
straight from the DB, where the flags really do live on giteaConfig.
The existing tests could not catch this because they build a synthetic
config already in DB shape, so a client/server mismatch is invisible to
them. The new tests push flags through the real config-mapper in both
directions and assert the derived flags equal what mapUiToDbConfig would
persist, so they fail if that mapping changes again. One test pins the
root cause directly: that giteaConfig in the API payload carries no flags.
* fix(ui): surface the mirror-options filter on desktop, and on organizations
The overrides filter only existed inside the mobile filter drawer, so on
desktop the Repositories page showed a "Custom" badge on overridden rows
with no way to filter to them. Being able to answer "which repos deviate
from my defaults" on this page is the reason the Configuration-page
listing was dropped, so desktop could not do the one job it was given.
Adds the control to the desktop filter row next to status and sort, using
the bare Select-with-placeholder style those use rather than the drawer's
labelled markup. The mobile control is unchanged.
The Organizations page had a wider version of the same gap: it renders the
"Custom options" badge but never had this filter on either layout, and
OrganizationsList did not filter on it at all. Added the predicate plus
the control in both its layouts, so the two pages behave the same.
Also folds hasOverrides into activeFilterCount on both pages and into the
Organizations clear-all reset. Without that the mobile filter badge
undercounted an active overrides filter, and clearing filters left it set.
* fix(ui): stop double-applying the mirrorMetadata switch when reading config
mirrorOptionsToFlags ANDed each metadata component with mirrorMetadata on
the way in. That is a write-path rule: mapUiToDbConfig already applies it
when persisting, so a config saved through the settings UI has it baked
into the stored flag. Applying it again on read double-applies it.
The read path does not need it. mapDbToUiConfig puts the raw stored values
into metadataComponents, so metadataComponents.issues is exactly
giteaConfig.mirrorIssues, which is the field gitea.ts and
gitea-enhanced.ts read. Mapping the components straight through is 1:1
with runtime behavior.
Double-applying was invisible while the stored state was self-consistent,
since false && false is still false. It diverged when the state did not
come from the UI write path, which env vars allow: MIRROR_METADATA=false
with MIRROR_ISSUES=true stores mirrorMetadata:false, mirrorIssues:true.
The runtime mirrors issues; the dialog reported off. Measured against a
config in that state, five flags misreported.
I previously described this as an unrecoverable loss in the API shape.
That was wrong. Both values reach the client separately and unmerged, so
the payload carries full information and the loss was one we introduced.
Rewrites the test that asserted the derived flags match what
mapUiToDbConfig would persist. Comparing against the write derivation is
what pinned the bug in place. It now asserts the property that matters,
that flags derived from the API payload equal the stored flags the runtime
reads, plus a case checking agreement with resolveMirrorOptions on an
inconsistent config. Both fail if the AND returns.
Clears three Dependabot alerts on the marketing site:
nanoid 3.3.16 -> 3.3.18 (infinite loop when size is zero)
js-yaml 4.3.0 -> 4.3.1 (quadratic CPU on !!omap resolution)
postcss 8.5.19 -> 8.5.26 (attacker-controlled source parsing)
All three are transitive and moved within their existing ranges, so the
change is version and integrity hashes only with no graph churn.
Verified with a frozen-lockfile install and a full site build.
GitHub restricted the stargazers API to repo admins and collaborators,
which broke the unauthenticated chart. Star History now needs a token
sealed into the image URL.
Uses a fine-grained token limited to this repository instead of a
classic public_repo token. The stargazers endpoint requires Contents
write, and classic public_repo grants that across every public repo the
account can push to, so scoping it to one repo keeps the blast radius
small. Legend moved to top-left to sit clear of the curve.
Animating text-indent forced a relayout every frame, which made the
marquee visibly choppy. The scroll is now a compositor-driven transform.
The content stays plain inline text while at rest so the native ellipsis
still renders, and swaps to an inline-block only for the duration of the
animation.
The hover target is now the whole repository cell instead of just the
text, via a shared MarqueeTrigger wrapper. Name and path scroll together
at the same speed, so the motion reads as one block.
Long repository names and owner/repo paths in the repositories table
wrapped to multiple lines, breaking row alignment. Both now truncate
with an ellipsis and scroll horizontally on hover to reveal the hidden
part.
The new MarqueeText component animates text-indent instead of a
transform because ellipsis rendering only works on inline content, and
a transform would require an inline-block wrapper.
- Build git-lfs with Go 1.25.12 (stdlib CVEs fixed in 1.25.10) and pull
golang.org/x/net past the fix for CVE-2026-39821; x/net 0.54.0 was
flagged critical in the image scan.
- Cap SARIF relatedLocations at 100 per result before upload. GitHub
rejects SARIF with >1000 related locations per result, and Docker
Scout exceeds that for common OS packages, so every upload since May
had failed silently (continue-on-error) and the Security tab was
frozen on a stale scan. With uploads flowing again, already-fixed
alerts (samlify, libgnutls via the existing apt-get upgrade) close on
the next scan.
Fixes the @better-auth/oauth-provider advisory (unbound resource
indicators could yield access tokens for unauthorized audiences,
Dependabot #55). No patched 1.6.x exists; 1.7.0-rc.4 is the first
patched line.
The 1.7 oauth-provider expects a wider schema: new nullable columns on
oauth_clients / oauth_access_tokens / oauth_refresh_tokens /
oauth_consents (back-channel logout, DPoP, resource indicators, refresh
token rotation) and three new tables (oauth_resources,
oauth_client_resources, oauth_client_assertions). Migration 0014 is
purely additive; validate-migrations gains the matching upgrade fixture.
Follow-up to #356. The conditional-request store was bounded only by
entry count; a single 100-PR page can run to a few hundred KB, so 5000
entries could grow to gigabytes. The store now also tracks approximate
body bytes (64MB budget by default) and evicts oldest-first until both
caps hold. A body larger than the whole budget is not cached at all.
Clients created with only a token (the metadata mirroring path) all
shared the "default" cache scope across users. They now fall back to a
SHA-256 hash of the token, so distinct tokens never share entries and
the raw token never appears in cache keys.
* feat(github): reuse ETags across syncs via conditional requests
The mirror re-lists every repository's pull requests on each scheduled
sync with `state: "all"`, and the Octokit client was created with the
throttling plugin but no conditional-request/ETag layer. Every sync
therefore re-downloaded unchanged data as full 200s and spent full
rate-limit budget.
Add an ETag cache wired into `createGitHubClient`: for each GET it
replays the previously stored `If-None-Match`, so GitHub returns
`304 Not Modified` (which does not count against the token's primary
rate limit) when nothing changed, and the cached body is reused. The
store is process-lifetime and scoped per user so ETags survive the
per-sync client re-creation without leaking data across tokens. Non-GET
requests are untouched.
Refs #355
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79
* test(github): cover conditional-request ETag cache
Add unit tests driving a real Octokit instance with a stubbed fetch:
the second GET replays `If-None-Match`, a `304` is transparently served
from cache as a 200 with the same body, responses without an ETag are
re-fetched, non-GET requests are never made conditional, and cache
entries are isolated by scope.
Refs #355
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79
* fix: key conditional-request cache by expanded URL
The request hook built the cache key from requestOptions.url, which is
still the route template (/repos/{owner}/{repo}/pulls) at hook time.
Every repo therefore shared one entry per user + endpoint, so with more
than one repo per user the stored ETag never matched and the 304 path
stopped firing. Expand the route via octokit.request.endpoint.parse
before building the key, and add a two-repo regression test that fails
on the shared-key behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79
The variable was defined and documented but never read; the database
path was hardcoded to <cwd>/data/gitea-mirror.db. It now accepts
sqlite://, the legacy file: scheme, or a plain path, with relative
paths resolving against the working directory and the parent directory
created on demand. Defaults are unchanged, including the compose files
that pass the historical default value through. Docs updated to match.
* docs: move canonical documentation to the website
The website now hosts the full documentation at /docs with a proper docs
layout: sidebar navigation, on-page table of contents, mobile nav, theme
support, canonical and OG meta, and overflow-safe code blocks and tables.
Ten pages, all rewritten from the current code rather than copied from
the old in-app docs: quickstart, deployment (Docker, Helm, Nix, LXC,
bare metal), configuration, environment variable reference,
notifications (all four providers including webhook payload signing),
authentication (including header auth), force-push protection,
architecture, advanced, and custom CA certificates.
This fixes every inaccuracy found in the docs audit: the wrong
raylabs/gitea-mirror image name, JWT_SECRET presented as the live auth
secret instead of BETTER_AUTH_SECRET, the missing auth env vars, both
wrong DATABASE_URL defaults, the contradicting starred-org default, and
health endpoint fields the API deliberately does not return.
The in-app /docs pages are retired: a stub redirects old bookmarks to
the website, and the sidebar and 404 links point there directly. The
markdown files under docs/ stay as the versioned offline reference;
NOTIFICATIONS.md now covers Gotify and Webhook. README links the docs
site, mentions notifications, and drops stale version markers. The app
viewport meta gains initial-scale=1.
* docs: mark new-repo notification as unimplemented, bump helm appVersion to 3.24.0
* feat(ui): redesign dashboard and configuration screens
New settings design language built from the design/giteamirror.pen file:
cards with icon headers and status footers, header-level enable switches,
toggle switches instead of checkboxes, uppercase section titles, selection
tiles with icon chips and a check on the active option, segmented controls,
and an indigo accent. Implemented via shared primitives in
src/components/config/settings-ui.tsx and applied across:
- Automation: header switches, schedule card, one-line auto-mirror copy
with info tooltip, full-width Repository Cleanup card with Skip/Archive/
Delete tiles and dry run row
- Notifications: segmented provider picker (ntfy/Apprise/Gotify/Webhook),
events card with per-event switches
- Connections: GitHub/Gitea connection cards with token creation guide and
field helpers, Repository Selection and Mirror Content cards covering
every mirror option, Organization Structure card with strategy tiles,
destructive update protection tiles (BETA label removed)
- Authentication: sign-in methods status card, identity providers restyle
- Dashboard: flatter stat cards, icon panel headers, indigo view-all links
All existing state handling, autosave and API behavior is unchanged.
Light mode keeps working via theme tokens. README and website screenshots
regenerated, docs references to renamed cards updated.
* fix(ui): design polish pass from local review
- Recent Activity rows get status icon circles (check, sync, sparkles, alert)
- Connections tab restructured: connection cards share a stretched grid row
so GitHub and Gitea stay equal height; Mirror Content moved to the right
column; forms split into placeable cards via a part prop
- Token guide panel: link moved to header as icon, larger text; redundant
card footers removed (scopes line, test-connection hint)
- Repository Selection gains a footer note; retention explanation moved
below the selector
- Authentication tab matches the design: side-by-side cards, row dividers,
disabled state-reflecting switches with info hints, footers; SSO dialog
restyled (segmented protocol tabs, field labels, indigo primary)
- Import GitHub Data button is the indigo primary; disabled state is muted
- Time format menu redesigned (locale pill, live examples, live clock in
the trigger); theme switcher moved to sidebar as icon segmented control,
system preference now persists correctly; legacy ModeToggle removed
- Automation timezone pill no longer shows stored legacy UTC as a choice
- Config tab bar wraps 2x2 on narrow screens instead of overflowing
- README, website and PR screenshots regenerated
Adds Webhook alongside ntfy, Apprise and Gotify: posts a JSON payload
(title, message, type, timestamp) to any URL, with an optional signing
secret that adds an X-Webhook-Signature header (HMAC-SHA256 of the body,
sha256=<hex>) so receivers can verify authenticity. Secret is encrypted
at rest like the other provider tokens. Settings UI section, provider
and service tests included. No database migration needed.
The check added in #350 only echoed a message (with backticks that bash
executed as command substitution, mangling it) and never failed the job,
so a stale bun.nix would still pass CI. Fail fast right after the
regenerate step instead, with a GitHub error annotation and the diffstat.
bun.nix (last regenerated in #298) drifted from bun.lock after the dependency updates in #348, leaving the Nix flake build broken: 254 packages missing and 195 stale entries. Regenerated with bun2nix; all 929 entries now match bun.lock exactly.
Also adds a CI step that detects drift after the regenerate step (made blocking in a follow-up commit).
Thanks @Cyberboss.
- Remove references to nonexistent /api/export and /api/repos/:id/logs endpoints
- Correct storage model: mirrored repos and LFS live in Gitea, not the data/ volume
- Fix default sync interval (daily, not 1 hour) and startup log line
- Add write:organization to required Gitea token scopes
- Remove nonexistent metrics endpoint and per-repo interval claims from Helm page
- Fix rate-limit advice (limits are per account, not per IP/token)
- Refresh stale comparison content (outage dates, BackHub/Rewind acquisition)
- Add missing git clone step to comparison quick start
Adds Gotify alongside ntfy and Apprise: new provider module posting to {url}/message with X-Gotify-Key auth, configurable default priority (errors always send at priority 8), token encrypted at rest like the other providers, settings UI section, and provider + service tests. No database migration needed.
better-auth 1.6.23 (fixes GHSA high stored XSS via javascript: redirect_uri), esbuild >=0.28.1 override for www (GHSA low), Astro 7 / @astrojs/node 11 / @astrojs/react 6 / @astrojs/mdx 7 / lucide-react 1.x (inline GitHub icon replaces removed brand icon) and all in-range updates on both the app and the website. The remaining @better-auth/oauth-provider medium advisory is patched upstream only in 1.7-rc and will be picked up when 1.7 stable lands.
bun's mock.module and the globalThis.fetch swap are process-wide; on CI
(bun 1.3.13) this file's mocks of @/lib/db and @/lib/gitea-enhanced leaked
into gitea-enhanced.test.ts and stuck-status-recovery tests, failing main.
The file now registers nothing in the shared test process and instead
re-runs itself via bun test in a child process where the mocks are contained.
The previous call-order counter diverged between the mocked flow and the
assertions when bun re-instantiates mock factories (green on bun 1.3.6
locally, red on 1.3.13 in CI). Ids are now a pure function of the org name.
Timestamps now follow the browser locale by default (previously hardcoded en-US/12-hour), with a clock toggle in the header for Auto / 12-hour / 24-hour, persisted in localStorage.
Fixes#342
Adds stuck-status recovery: repositories (and orgs) stranded in an in-flight status by a crash/restart are reset to failed with an explanation, on container start and every scheduler tick, guarded by the existing 2h liveness window.
Fixes#339
Config saves now preserve every field the settings form doesn't expose (mirror interval and other env-only options) instead of resetting them to defaults.
Fixes#338
- Add behavioral tests exercising mirrorGitHubOrgToGitea end-to-end down to
the migrate HTTP payload: org-level override, per-repo override, mixed
strategy uid, starred-repo mode, and preserve/single-org/flat-user
no-override regression paths. All four bug-scenario tests fail on main
and pass with PR #344 applied.
- Fix the top-level log that claimed 'flat-user strategy' when the mixed
strategy falls into the same branch.
Routes the bulk Mirror Organization path and crash recovery through the canonical destination resolver (getGiteaRepoOwnerAsync), so org-level and per-repo destination overrides are honored, the mixed strategy no longer sends org repos to the user's personal account (previously uid was dropped from the migrate payload and Gitea defaulted to the authenticated user), and starred repos follow starred-repo mode even when swept up in a bulk org mirror.
Fixes#343
The old giteamirror.com domain is being retired to avoid paying for
per-project domains. Repoint all canonical URLs, og:url, the homepage
siteUrl, robots.txt sitemap ref, and sitemap.xml loc from the old
gitea-mirror.com domain to the new gitea-mirror.raylabs.io site.
Three related fixes for the "repos keep getting archived and then fail to
sync with HTTP 405" report:
1. Orphan cleanup no longer archives on bulk-list absence alone.
Repos added via the "+" Add Repository dialog (foreign owner, not
starred) can never appear in the authenticated bulk fetches, so every
cleanup cycle deterministically flagged them as orphaned and archived
them. identifyOrphanedRepositories() now runs a targeted per-repo
confirmation (starred check or repos.get) and only treats a clean 404
as gone; any other outcome fails safe.
2. The archived-* rename is persisted. archiveGiteaRepo() now returns
the actual post-rename name and the cleanup service records it in
mirroredLocation, so the DB no longer points at a name that only
301-redirects.
3. Sync self-heals repos renamed in Gitea/Forgejo. Requests to a
renamed repo get a 301; fetch follows it, downgrading POST to GET,
which lands on the POST-only mirror-sync endpoint as a 405. The sync
candidate loop now adopts the canonical owner/name from the GET
response body before POSTing, tries an archived-{name} fallback for
archived repos (guarded by an original_url source match), and keeps
archived repos archived: no mirror-interval PATCH, status stays
'archived' per the documented Manual Sync contract.
Also hardens mirrorGitHubReleasesToGitea to derive GitHub coordinates
from fullName so Gitea-side names can never leak into GitHub API calls.
Verified end-to-end on Forgejo 15.0.3 (rootless): pre-fix reproduces the
exact 405; post-fix the stale-name sync succeeds (GET stale -> 301, GET
canonical -> 200, POST canonical mirror-sync -> 200), archived repos keep
interval "0s" with no PATCH issued, and non-archived renamed repos heal
and get the configured interval applied.
* fix(releases): send Gitea release title as `name`, not `title` (#334)
Gitea/Forgejo expose the release title through the JSON field `name`
(the API Go struct is `Title string `+"`"+`json:"name"`+"`"+`). The release
create and update payloads sent `title:` instead, which Gitea silently
ignores, so every mirrored release landed with a blank title.
Verified live against Gitea 1.24.7: a POST/PATCH with `title` yields
`name: ""`; the same call with `name` sets the title correctly. The
update path also self-heals previously-mirrored releases whose names were
left blank, since the existing-vs-expected name comparison already drives
a PATCH.
Adds gita-release-name.test.ts, which drives the real
mirrorGitHubReleasesToGitea create/update paths with a mocked fetch and
asserts the payload carries `name` (and never `title`).
* fix(issues): reconcile labels on issue/PR update via the labels sub-resource (#334 sibling)
Gitea/Forgejo's `EditIssueOption` has no `labels` field (only
`CreateIssueOption` does), so a `labels` key in a `PATCH .../issues/{index}`
body is silently dropped — the same silent-ignore class as the release
`title` vs `name` bug. The issue and PR-as-issue update paths sent `labels`
in the PATCH body, so label changes never propagated onto already-mirrored
issues (and a deadlock-orphaned issue recovered via PATCH never got its
labels).
Fix: add `reconcileGiteaIssueLabels`, which replaces the label set via
`PUT .../issues/{index}/labels` (idempotent — adds new, removes deleted).
Call it on the two issue update paths and the two PR-issue update paths,
and drop the dead `labels` key from those PATCH bodies. Labels on freshly
created issues still come from CreateIssueOption on the POST.
Verified live against Gitea 1.24.7 (PATCH ignores labels; PUT applies them)
and end-to-end (a drifted mirrored issue reconciled from no-labels to its
GitHub label set). Adds gitea-issue-labels.test.ts driving the real
mirrorGitRepoIssuesToGitea update path; the test carries a self-contained
http-client mock so it is immune to another suite's global module mock.
* test: make #334 regression tests deterministic via pure payload builders
The prior tests drove the real mirror functions with a global `fetch` mock.
That is order/version-fragile: another suite installs a process-global
`mock.module("@/lib/http-client")`, and bun 1.3.13 (CI) runs test files
concurrently, so `globalThis.fetch` races across files and
`isRepoPresentInGitea` (raw fetch) intermittently sees the wrong mock —
green locally on bun 1.3.6, red in CI.
Extract the payload construction into pure, exported builders and assert on
those instead (the repo's existing `classify*` pattern): buildGiteaReleasePayload
(create+update send `name`, never `title`), buildGiteaIssueEditPayload (edit
body never carries `labels`), buildGiteaIssueLabelsPayload (labels sub-resource
body). Behavior is unchanged — the builders return the exact same objects the
call sites built inline — and the fixes remain verified live on Gitea 1.24.7.
Release creation failed on some Gitea/Forgejo instances with
"HTTP 404: The target couldn't be found", so no release (and therefore no
assets) was ever created — re-syncing never recovered.
Root cause: the create payload always sent `target: target_commitish`
(e.g. "main"). When the release's git tag is not yet present in the Gitea
mirror — which happens when Gitea's own git mirror clone lags behind the
metadata sync — Gitea tries to *create* the tag from `target`; if that ref
can't be resolved it returns a generic 404 ("The target couldn't be
found"), and if it can, it would create a brand-new tag at the wrong commit.
Reproduced the reporter's exact stack (Forgejo 15 rootless + read_only +
cap_drop ALL + postgres, plus Gitea 1.20-1.26 and Forgejo 1.21-15): a
healthy repo always succeeds — the 404 only occurs when the tag is absent
at create time.
Fix:
- Before creating a release, verify the git tag already exists in Gitea.
If it isn't synced yet, skip it (logged) and let a later sync create it
once the mirror has the tag — never create a tag via `target`.
- Drop the `target` field from both the create and update payloads. For a
mirror the tag is synced from upstream, so Gitea attaches the release to
the existing tag; `target` is unnecessary and is what triggers the 404.
- Surface skipped-missing-tag releases in the summary log for diagnosability.
Verified end-to-end against the real mirror function on a Forgejo instance:
a release whose tag exists is created with its assets; a release whose tag
was removed is skipped cleanly (no 404, no bogus tag) and picked up once the
tag is present.
Release assets were uploaded only on the create path of
mirrorGitHubReleasesToGitea(). When a Gitea release already existed, the
update path PATCHed the changelog/title and `continue`d without ever
touching assets. So any release whose assets were not fully uploaded on
that single create-path run — first sync interrupted, a transient
download/upload failure, large multi-MB assets, etc. — stayed permanently
asset-less, and re-syncing always hit the update path and could never
recover it. Asset failures were also swallowed to console.error, so the
job still reported success (the "no errors in the logs" in #331).
Reproduced on a real Forgejo pull-mirror with shauninman/MinUI: a GitHub
release with two ~35-40MB binaries became a Gitea release with 0 assets
(just Forgejo's auto-generated source archive), and re-syncing left it at
0 while logging "Updating existing release".
Fix:
- Add reconcileReleaseAssets(), an idempotent reconciler run on BOTH the
create and update paths. It compares Gitea's existing attachments to
GitHub's by name+size: skips matches, uploads missing ones, replaces
size-mismatched copies. Existing broken releases self-heal on next sync.
- Extract the pure decision into classifyAssetsForReconciliation() for
unit testing (the absence of asset tests is why this slipped past #310).
- Surface asset upload counts in the summary and emit a visible warning
when any fail, instead of silently swallowing them.
- Add 5 unit tests covering the broken state, partial backfill,
idempotency, size-mismatch replacement, and the no-assets case.
Verified end-to-end against the real function on a Forgejo pull-mirror:
0 -> 2 assets backfilled, already-present assets skipped (no re-download),
second run uploads 0.
The "Name collision strategy" dropdown (starredDuplicateStrategy) never
persisted: the field was absent from both directions of the UI<->DB config
mapper. On save, mapUiToDbConfig dropped it before the DB write; on load,
mapDbToUiConfig never read it, so the UI reset to the "suffix" (repo-owner)
default. Mirror logic in gitea.ts then read undefined and also defaulted to
suffix — so repos really were created with that pattern regardless of the
user's choice. It has been broken since the field was introduced.
- Map starredDuplicateStrategy in mapUiToDbConfig and mapDbToUiConfig
- Add STARRED_DUPLICATE_STRATEGY env var for parity (reporter could not
work around it via compose because no env var existed) + docs
- Round-trip tests covering save, load, and the missing-field default
Repository discovery requested the `organization_member` affiliation
unconditionally, so repos from every org a user belongs to were imported —
even orgs they never explicitly added. `skipPersonalRepos` only dropped
user-owned repos and left org repos unfiltered, which surprised users who
expected "only mirror org repos" to mean "only the orgs I chose" (reported
on #304).
Wire up the previously-dormant `includeOrganizations` config field as an
opt-in allowlist: when non-empty, only repos owned by the listed
organizations are imported. Empty = all org repos (backward-compatible).
Owned and collaborator repos are never restricted, so it composes cleanly
with `skipPersonalRepos`.
- Filter org repos by the allowlist in getGithubRepositories
- Add includeAllOrgsOverride so the cleanup service bypasses the allowlist
and never false-orphans a previously-mirrored repo from an org the user
later removes from the list
- UI control under Filtering & Behavior; INCLUDE_ORGANIZATIONS env var
- Case-insensitive dedup/trim in the UI<->DB mapper round-trip
- 7 unit tests covering the filter, composition, and the cleanup override
* Handle indexing when shift + clicking in the repository table
* Move the buttons when selecting rows
* Add in a bulk delete func in the repositories table
* Add bulk delete handler
* Make the single action use the bulk delete
* Delete the single repository id handler
- Add `skipPersonalRepos: z.boolean().default(false)` to githubConfigSchema
- Filter out user-owned repos in getGithubRepositories when flag is true
- Wire ONLY_MIRROR_ORGS env var to skipPersonalRepos in env-config-loader
- Add checkbox UI in GitHubMirrorSettings Filtering & Behavior section
- Round-trip skipPersonalRepos through config-mapper (UI ↔ DB)
- Add skipPersonalRepos to AdvancedOptions TypeScript type
- Mark include/exclude arrays in configSchema as unused/reserved
- Update ENVIRONMENT_VARIABLES.md to document ONLY_MIRROR_ORGS effect
* fix(scheduler): make enabled flag authoritative for auto-start
checkAutoStartConfiguration() and performInitialAutoStart() previously
used `scheduleEnabled || hasMirrorInterval`, allowing a configured
GITEA_MIRROR_INTERVAL to trigger boot-time auto-start even after the
user disabled scheduling via the UI toggle.
env-config-loader already writes scheduleConfig.enabled=true when
GITEA_MIRROR_INTERVAL is set at container startup, so the interval is
a timing detail, not an enable signal. The documented env-var contract
is preserved: GITEA_MIRROR_INTERVAL at boot → env-config-loader sets
enabled=true → auto-start fires. But a later UI disable now sticks.
Add a focused unit test for the gate logic.
* fix(backup): always derive clone URL from user-configured Gitea URL
The pre-sync backup preferred repoInfo.clone_url, which reflects
Gitea's ROOT_URL setting. In Tailscale MagicDNS deployments (and any
setup where ROOT_URL is an external address), this URL is unreachable
from the app itself, causing bundle backup to fail.
Always build the clone URL as:
${config.giteaConfig.url.trimEnd('/')}/${owner}/${repo}.git
This matches the URL the app already uses for all other Gitea API
calls and is guaranteed reachable.
* feat(jobs): cancel-pending endpoint + fix misleading Delete All copy
Add POST /api/job/cancel-pending that sets the current user's
repositories with status "imported" or "failed" to "ignored",
preventing the scheduler from re-queuing them. In-flight "mirroring"
rows are left alone. Returns the count and logs one activity entry.
Fix the "Delete All Activities" dialog to clearly state it only clears
the history log and does not stop pending work. Rename button/title to
"Clear History" so intent is unambiguous.
Add a "Stop Pending Mirrors" button (StopCircle icon, amber) in both
mobile and desktop activity log toolbars, with a confirmation dialog
explaining repos are set to Ignored and can be re-enabled from the
Repositories page.
* fix(sync): actionable 405 error for non-pull-mirror repos
Gitea returns HTTP 405 with an empty body when the target repository is
no longer a pull mirror — e.g. the mirror was auto-disabled by Gitea or
the repository lost its mirror state after a manual edit.
Previously this fell through to the generic error handler which stored
the raw HttpError message (often empty) giving the user no guidance.
Now a 405 response is caught alongside the existing 400 handler and
sets the repository to "failed" with an actionable error message:
"Gitea reports this repository is not a pull mirror (HTTP 405).
In Gitea check Settings → Mirror Settings; if the mirror section is
missing, delete the repository in Gitea and re-mirror it from
gitea-mirror."
The same message is written to the activity log for visibility in the
dashboard.
Root cause (Theory A): the `needsRecreation` check compared GitHub
published_at-based expected indices against Gitea's API order. Gitea mirror
repos sort releases by tag-commit date, which can permanently disagree with
published_at order (e.g. unaconfig_dart v0.1.0 published after v0.1.1 but
tagged before). This made `currentExpectedIdx < nextExpectedIdx` evaluate
true on every sync, triggering delete-all-and-recreate forever — spamming
Gitea's activity feed with "released X" events (#310).
Fix: replace the destructive order-check machinery with set-based
reconciliation via `classifyReleasesForReconciliation`. Releases are
created when missing in Gitea and skipped (or PATCH-updated if content
drifted) when already present. No deletions are ever triggered by ordering.
Retain the existing release-limit trimming (retention cleanup) unchanged.
Also removes the 1-second per-release delay that was only needed for the
creation-order dance, significantly speeding up initial mirrors.
Adds unit tests covering: normal ordered repos, the unaconfig_dart inversion
fixture, missing→create, present→skip, and edge cases.
Starred (and other) repos duplicated on every re-mirror (starred/Repo,
Repo-owner, Repo-owner-1, ...) because the existence check only asked
"does a repo with this name exist?" and never "is the existing repo a
mirror of THIS same source?". The repo's own prior mirror counted as a
collision, so generateUniqueRepoName bumped to the next suffix each run,
repointing mirroredLocation at the newest copy. Under a single re-call,
3 concurrent/retried jobs each computed a DIFFERENT suffixed name, so the
location-based in-flight guard never matched and the race produced extra
copies.
Fix (source-identity aware):
- New shared helper src/lib/utils/mirror-source-match.ts:
- normalizeCloneUrl / cloneUrlsMatch: credential-, .git-, slash- and
host-case-insensitive clone URL comparison.
- isMirrorOfSource: a Gitea repo is "ours" only if it is a mirror AND
its original_url matches this repo's source.
- findExistingMirror: resolves an existing same-source mirror via the
recorded mirroredLocation first (survives strategy changes — #309),
then the base candidate name.
- classifyCandidateName: pure available/reusable/taken decision.
- gitea-enhanced: export GiteaRepoInfo and add original_url (Gitea's
recorded migration source) for source matching.
- Both create paths (mirrorGithubRepoToGitea, mirrorGitHubRepoToGiteaOrg):
run findExistingMirror BEFORE name generation; on a hit, reuse that
location and route into the existing "already mirrored" handling rather
than calling generateUniqueRepoName. Names now converge under
concurrency so the in-flight guard becomes effective.
- generateUniqueRepoName is now source-aware: an occupied name held by a
mirror of the SAME source is reused (no suffix); suffixing only happens
on a genuine different-source collision, preserving #95/#236 behavior.
The per-user DB claim check is retained so two users mirroring the same
source into a shared org stay separated.
- Phantom-fork guard (#309): the existingRepoInfo.mirror branches now
verify same-source before marking "mirrored"; on mismatch they fall
through to unique-name generation and create a separate mirror.
- Scheduler: a `failed` repo whose mirroredLocation still resolves to a
live same-source mirror is routed to syncGiteaRepo instead of re-create,
breaking the failed-metadata re-create loop cheaply.
- Remove dead src/lib/starred-repos-handler.ts (zero importers across all
git history); its correct base-name/.mirror reuse logic now lives in the
shared helper.
Tests: src/lib/utils/mirror-source-match.test.ts (30 cases) covers URL
normalization, reuse at base name, reuse via mirroredLocation across a
strategy change, genuine different-source collision (suffix), phantom
fork, stale mirroredLocation fallback, per-user DB-claim separation, and
the suffix-vs-reuse classification. Full suite: 319 pass, 0 fail.
Migration 0013 runs as a single transaction that rebuilds `organizations`
and then `ALTER TABLE sso_providers ADD saml_config` / `ADD domain_verified`.
On instances where those columns were already created outside Drizzle (declared
in schema.ts and added via db:push / an SSO-register round-trip on an
intermediate build), the ADD throws "duplicate column name: saml_config". That
rolls back the entire 0013 transaction, so 0013 is never recorded in
`__drizzle_migrations` and is retried — failing identically — on every boot,
crash-looping the server.
Add a pre-migrate repair (mirroring the existing repairFailedMigrations() for
the 0009 case): when 0013 is unrecorded but the columns already exist, preserve
any real SAML provider config, drop the stranded columns so the canonical 0013
runs in full (organizations rebuild included), then restore the preserved
values once the columns are re-added. No-op on fresh installs, clean upgrades,
and already-migrated databases.
This lets affected instances recover automatically on the next boot after
upgrading — no manual SQLite surgery required.
- src/lib/db/migration-repairs.ts: repairDuplicateSsoColumns + restoreSsoDataAfter0013
- src/lib/db/index.ts: wire both around migrate()
- scripts/validate-migrations.ts: cover the broken-upgrade + data-preservation path
Resolves#306. SSO sign-in via OIDC (Authentik / Keycloak / etc.) now links the
SSO identity to an existing email/password admin instead of bouncing to /login
with `?error=UNKNOWN`. Account-linking is gated on the operator-supplied
**Domain** field — cross-domain claims from a compromised IdP are refused.
Also bundles the deprecated `oidcProvider` → `@better-auth/oauth-provider`
migration. **Operators using the OAuth-provider feature must rotate registered
client secrets after upgrade** (legacy plaintext → hashed storage; see the
0012 migration notes).
Verified end-to-end on the pr-307 image against a real Authentik instance:
SSO login lands on the dashboard, `accounts` table gets both `credential` and
`authentik` rows for the same user. See PR description for full details.
Header / forward authentication has been end-to-end broken since the
v3 rewrite. The middleware populated `context.locals.user` from
trusted upstream headers (Authentik / Authelia / oauth2-proxy /
Caddy), but never minted a Better Auth session, and never set a
cookie. Server-rendered pages saw the user, but the React SPA's
`/api/auth/get-session` call hit Better Auth's handler — which only
reads its session cookie — and got `null`. The auth guard then
redirected to `/login`, even though the upstream proxy had already
authenticated the user.
Reported on issue #29 by @lanrat with a clean repro on v3.16.1.
Fix: add a small Better Auth plugin (`header-auth`) that exposes
`POST /api/auth/sign-in/header`. The endpoint validates the trusted
headers via `authenticateWithHeaders`, creates a real session row via
`internalAdapter.createSession`, and attaches the `Set-Cookie` via
`setSessionCookie` — the same pattern the magic-link, anonymous, and
phone-number plugins use after their respective verification steps.
The Astro middleware now calls this endpoint when no cookie session
exists and header auth is enabled, forwards the `Set-Cookie` onto the
outbound response, and populates `context.locals` from the minted
session. After the first request the browser has the cookie; every
subsequent request takes the normal cookie-auth fast path and the
bridge doesn't fire.
Fail-open everywhere: any endpoint failure (header auth disabled,
auth rejected, DB blip, malformed response) returns null from the
bridge and the request proceeds as anonymous. A broken header-auth
configuration must never lock everyone out of the cookie-auth path.
Tests:
- `auth-header.test.ts` — unit tests for `extractUserFromHeaders` and
`isHeaderAuthEnabled`, including lanrat's reported config shape
(same header for username and email).
- `auth-header-plugin.test.ts` — locks down plugin id, endpoint key,
path, and method so an accidental rename can't silently break the
middleware bridge.
- `auth-header-bridge.test.ts` — covers the cookie-extraction logic
and the fail-open paths (non-2xx, thrown error, malformed JSON,
missing fields, no Set-Cookie attached).
Stacks on top of #301 (better-auth 1.6.11 bump).
Refs: #29
Updates `better-auth` and `@better-auth/sso` from 1.5.5 to 1.6.11 to
pick up the patch fixes that have landed since (OAuth state CSRF
verification, scrypt non-blocking password hashing, account cookie
comparison fix, session freshness alignment, etc.). All 270 local
tests pass against the new version.
The only behavioral surface to watch:
- `freshAge` now aligns with session `createdAt` instead of
`updatedAt`. Not applicable here — we don't gate anything on
session freshness.
- 1.6.2 adds OAuth state-parameter CSRF verification. Applies to
OIDC/SAML SSO flows; no code changes needed.
- 1.6 emits a deprecation warning for `oidc-provider` in favor of
`@better-auth/oauth-provider`. The plugin still works in 1.6.x;
migrating it is a separate cleanup.
Prep work for an upcoming header-auth fix (issue #29 follow-up).
* fix: stop snapshot-row zombies + flapping force-push on deleted branches
Two bugs caused Simple-WP-Helpdesk to accumulate one orphan
"Snapshot created" job row per scheduled sync — 7 zombies in 24h
against a deleted GitHub branch (`fix/v4.0.2-webhook-comment-author`).
(1) gitea-enhanced.ts:522 created the post-snapshot job record with
status="syncing". The snapshot was already complete at that point
(createPreSyncBundleBackup had returned), and no later code path
advanced the row to a terminal status — it just lingered. Set
status="synced" to reflect reality.
(2) force-push-detection treated any Gitea branch missing from GitHub
as a "deleted" force-push. But gitea-mirror is one-way (GitHub →
Gitea), so deletions never propagate back to the Gitea mirror —
the branch lingers in Gitea forever, the missing-from-GitHub condition
holds on every subsequent sync, and detection re-fires endlessly.
Each re-fire triggered backupBeforeSync=true to take a fresh snapshot
of the exact same state we already backed up the previous cycle.
Fix: detector accepts an `acknowledgedDeletions: { branch, giteaSha }[]`
list (persisted in RepositoryMetadataState). A Gitea branch missing
from GitHub is suppressed when its current giteaSha matches an entry.
If the giteaSha later changes (branch restored, then re-deleted with
new history), the entry won't match and detection fires again — back
up the new state, then acknowledge the new SHA. After a successful
snapshot, the call site appends each just-backed-up "deleted" branch
to the acknowledged list and persists the metadata blob.
Hoists parseRepositoryMetadataState above the backup-strategy block so
both the detection-time read and post-snapshot append happen against
the same in-memory state object; the existing metadata-mirror block
just stops re-declaring it.
Adds 9 new test cases covering:
- deleted branch suppressed when acknowledged at matching giteaSha
- re-flagged when giteaSha differs (restored-then-redeleted)
- mixed deletions (only matching one suppressed)
- undefined list = back-compat (existing callers unaffected)
- acknowledgedDeletions does not suppress "diverged"
- metadata-state parse/serialize round-trips the new field
- legacy metadata defaults acknowledgedDeletions to []
- malformed entries dropped without throwing
All 76 tests in the affected suites pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: acknowledge deletion before backup, not after, to fix concurrent-sync race
When two sync invocations fire for the same repo within the same backup
window (observed in prod via UI trigger pipeline: a single click ends up
producing two parallel processWithResilience callbacks that both run
syncGiteaRepoEnhanced), the second invocation's createPreSyncBundleBackup
short-circuits (file already exists from the first), so the second
invocation never reached the acknowledge-push that lived inside the
backup try block. Then both invocations wrote metadata: invocation A
with acknowledgedDeletions=[forged], invocation B (with its stale
in-memory state) with acknowledgedDeletions=[], and B's write landed
last — overwriting A.
Net result: the deletion never got acknowledged, and the next sync
re-detected it, the next-next sync re-detected it, etc. Same flapping
behavior as before the fix.
Fix: move the acknowledge-push to run right after detection, based on
detectionResult.affectedBranches alone. Semantically correct — once
we've detected the branch is gone from GitHub, we know it's a permanent
deletion; whether THIS particular invocation took a backup or
short-circuited is orthogonal (a previous backup on disk is fine, and
in the rare case where no backup ever succeeded, the user can re-trigger
a manual backup). Both concurrent invocations now push the same entry;
both write the same metadata; race is benign.
Verified locally: 59 tests pass across force-push-detection,
gitea-enhanced, repo-backup.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Header auth has been a working feature since v2.x but was missing from
SSO-OIDC-SETUP.md, leading users to think it was dropped in the v3
rewrite (see #29). Adds a dedicated section covering env-var config,
Authentik + Authelia examples, lookup order, verification, and the
must-strip-inbound-headers security checklist.
Paginate /milestones and /labels with Link header + X-Total-Count fallback, and pass state=all on milestones so closed milestones aren't re-POSTed on every sync. Caches newly-created items in the in-memory dedup set.
The previous middleware logic gated recovery behind a
once-per-process flag. After the first request handled the boot-time
recovery check, the resume codepath never fired again — even when
new interruptions appeared later in the process lifetime.
Symptom: a sync that started after server boot, crashed mid-flight
(deadlock retry hitting maxRetries, network blip, container
restart of an upstream service, etc.) and never reached the resume
codepath would sit at `inProgress=true, lastCheckpoint=never`
forever. The periodic detector kept finding the stuck row and
logging `Found 1 interrupted jobs:` on every poll (driven by the
health endpoint via `hasJobsNeedingRecovery`), but the resumer
(`resumeInterruptedJob`) was only invoked from `initializeRecovery`
which the middleware never re-called.
Fix: replace the one-shot gate with an in-flight latch
(`recoveryInFlight`) that's released in a `finally` block. Throttling
of actual recovery work is delegated to the existing 5-minute
`skipIfRecentAttempt` check inside `initializeRecovery()`, which is
the right place for it. `recoveryInitialized` is kept and only used
to control whether the "first run" log lines fire.
Secondary fix: `findInterruptedJobs` previously logged
`Found N interrupted jobs:` unconditionally on every call, including
from passive polls in `hasJobsNeedingRecovery` (which the health
endpoint and middleware probe call frequently). That produced one
log line per poll per stuck job for as long as the job stayed stuck.
Make logging opt-in via a `logFound` parameter, default off; the
active recovery cycle in `initializeRecovery()` opts in so the
operator-facing log still surfaces which jobs are being worked on.
Related: #268 (partially addressed). The PR #280 / v3.15.7 fix was
about a JS scoping bug that made the *initial* migrate call's
catch path crash before transitioning the repo to `failed`. That
was one cause of stuck `mirroring` state; this PR addresses the
follow-on issue that even when a job *is* correctly detectable as
interrupted, the post-startup recovery path never re-engages.
Adds `orchestrator-resume-after-startup.test.ts` using the
structural-source test pattern from
`gitea-mirror-failure-recovery.test.ts` so the four guarantees
(no static gate; in-flight latch released in finally; logging
opt-in; active path opts in) are enforced without heavy mocks.
mirrorGitRepoIssuesToGitea and mirrorGitRepoPullRequestsToGitea both
had two compounding bugs that produced duplicate Gitea issues/PR-as-
issue rows on every sync against any non-SQLite backend.
(1) Pagination on existing-issues / existing-PRs / per-issue-comments
was wrong.
The loops paginated with `limit=100` and broke on `pageX.length <
itemsPerPage`. Gitea caps response size at server-side
[api].MAX_RESPONSE_ITEMS (default 50), so the very first page
already looks "short" and pagination terminated after one page. The
existing-issue and existing-PR maps were built from only ~50 items
per repo, so every issue/PR past page 1 was treated as new on every
sync and re-created via the CREATE branch.
Naive removal of the short-page break (relying only on "break on
empty page") doesn't work either: for some Gitea endpoints Gitea
returns the same data on every page when the page is past the
actual end instead of returning [], so the loop runs forever.
Fix: use the Link header (RFC 5988). If `rel="next"` is absent,
terminate pagination. Applied to: existing-issues pre-fetch,
existing-PRs pre-fetch, and per-issue comments fetch.
(2) Retry-after-deadlock duplicated issues/PRs even when the map
was correct.
Gitea's CreateIssue handler commits the issue insert in one
transaction and then deadlocks on the subsequent addLabel /
UPDATE repository transaction. The issue row is committed and
visible, but the in-memory dedup maps are never refreshed between
retries — processWithRetry re-invokes the callback, sees
existingIssue === undefined from the stale map, and creates a fresh
duplicate via httpPost.
Reproduces deterministically on MySQL (Error 1213 / 40001) and
PostgreSQL (40P01); SQLite escapes because writes serialize globally.
Fix: defensive recheck via httpGet by [GH-ISSUE #N] (issues) or
[PR #N] (pull requests) before the create call, with PATCH
fall-through when found. Applied to the issues create path AND
both create paths in the PR mirror (enriched + basic-fallback).
Also cache freshly-created items into the dedup maps after a
successful create so subsequent retries of the same per-item
callback don't lose track of it.
Adds gitea-issue-dedup-on-retry.test.ts using the structural-source
test pattern from gitea-mirror-failure-recovery.test.ts so all
guarantees are enforced without heavy module mocks (8 tests).
Same hardening principle as #293 for GitHub Actions: pin to an immutable
identifier so a future tag move can't silently change what we build against.
- Bumps oven/bun from 1.3.13 to 1.3.14 (released 2026-05-13)
- Pins to multi-arch digest sha256:9dba1a1b...db6f rather than just the tag
- Applies to both base and runner stages
Adds an account menu under the avatar in the header with Change password and
Change email actions, each opening a small dialog. Calls Better Auth's existing
change-password / change-email endpoints — no new API routes.
- Enables `user.changeEmail` in Better Auth with `updateEmailWithoutVerification`
since the app runs with email verification disabled and no email sender is
wired up
- Hides Change password for SSO-only users (no `credential` provider account),
fails open if the listAccounts probe errors
- Resolves discussion #291 ("Change user password/email")
Tags are mutable. A compromised maintainer (or a maintainer's compromised
machine) can force-move v-tags to point at malicious commits, and any workflow
using `@vN` picks up the malicious code on its next run — see the recent
`actions-cool/issues-helper` / `maintain-one-comment` incident exfiltrating
credentials from `Runner.Worker` memory.
This commit pins every third-party action in the two workflows that handle
secrets (GHCR push, Docker Hub login, Scout token) to immutable 40-char SHAs,
with a trailing comment naming the release version for readability. SHAs are
the latest released tag at time of pin.
The two DeterminateSystems actions were on `@main` — a *branch* ref that moves
on every push, materially worse than a tag — and are now pinned to the latest
release SHAs (v22 / v13).
First-party `actions/*` and `github/codeql-action` are left on tags for now;
they're a separate, lower-risk follow-up.
Removed 5 overrides whose constraints have since been picked up naturally
by the transitive dep graph. Verified by removing each and confirming the
resolved version (and the dep tree) is identical to what the override
produced:
- defu ^6.1.7 → still resolves 6.1.7
- fast-xml-parser ^5.5.6 → still resolves 5.5.6
- node-forge ^1.3.3 → package not in tree at all (override was dead)
- rollup >=4.59.0 → still resolves 4.59.0
- svgo ^4.0.1 → still resolves 4.0.1
Kept overrides that are still doing real work:
- @esbuild-kit/esm-loader → npm:tsx@^4.21.0 — deliberate replacement shim
- @xmldom/xmldom ^0.8.13, devalue ^5.8.1, fast-uri ^3.1.2,
fast-xml-builder ^1.1.7, kysely ^0.28.17 — active CVE pins (#289)
- lodash ^4.18.1 — pins to the newer 4.18.x line over the legacy 4.17.x
that transitive deps still pull
- picomatch ^4.0.4 — without it, picomatch@2.3.2 is added as a duplicate
copy via a transitive that asks for 2.x
Future drift would be caught by Dependabot + the weekly Docker Scout
scan; the overrides above remain because they currently affect the tree.
Patches 9 Docker Scout HIGH alerts surfaced by the weekly image scan:
- @xmldom/xmldom 0.8.12 → 0.8.13 (CVE-2026-41672/3/4/5)
- devalue 5.6.4 → 5.8.1 (CVE-2026-42570)
- kysely 0.28.16 → 0.28.17 (CVE-2026-44635)
- fast-uri (new override) → ^3.1.2 (CVE-2026-6321, CVE-2026-6322)
- fast-xml-builder (new override) → ^1.1.7 (CVE-2026-44665)
All five resolve to fixed versions after `bun install`. Tests and astro
build pass locally.
Remaining open Docker Scout alerts (git-lfs Go stdlib, gnutls28, nghttp2)
are base-image or upstream-binary issues, not addressable via npm.
Previously, mirror of issues/pull-requests/labels/milestones was
guarded by !metadataState.components.<component>, so once a repo had
been mirrored the metadata path was permanently skipped with logs
like "Issues already mirrored; skipping to avoid duplicates".
This meant title changes, new comments, label updates and milestone
edits on the source GitHub repo were never propagated, even when the
user explicitly clicked Sync.
The underlying mirror* functions already handle idempotent updates:
issues and PRs are matched by [GH-ISSUE #N] / [GH-PR #N] markers in
the title and PATCHed in place, labels are deduped by name, milestones
by title. The releases path already runs unconditionally for the same
reason ("always allowed to rerun for updates"); aligning the other
metadata paths with it.
Touched: mirrorGithubRepoToGitea, mirrorGitHubRepoToGiteaOrg, and the
syncGiteaRepoEnhanced path in gitea-enhanced.ts. Updated the
"already-synced-repo" test to assert reconciliation runs on resync.
Affiliation was set to "owner,collaborator", omitting repos owned
by orgs the user belongs to. As a result:
- main sync, scheduler, and cleanup never saw org repos
- orgs appeared empty unless manually re-added via /api/sync/organization
- restart archived previously-mirrored org repos as orphans
GitHub's API default is owner,collaborator,organization_member;
restoring it fixes both symptoms with no other code changes.
GitHub's listForAuthenticatedUser defaults to returning every repo the
user has access to (owner + collaborator + organization_member), which
imports a lot of noise for users who only want their own repos.
Adds an `includeCollaboratorRepos` toggle, defaulting to true to preserve
existing behavior. When disabled, the affiliation filter scopes the API
call to "owner" only.
The cleanup service overrides the filter to always include collaborator
repos when computing the "what's still on GitHub" list. Without this,
toggling the option off would mark previously-mirrored collab repos as
orphaned and archive/delete them from Gitea.
Wired through the schema, both UI<->DB mappers, the env-config loader
(with new INCLUDE_COLLABORATOR_REPOS env var), and the settings UI.
The fix in v3.15.8 made scheduleConfig.autoMirror an independent trigger
in the scheduler, but it remained reachable only via the AUTO_MIRROR_REPOS
env var. This adds a UI checkbox under the Automatic Syncing section so
the option can be toggled per-config without touching the environment.
The toggle is conditional on scheduling being enabled (since auto-mirror
without a scheduler is meaningless) and is independent of the existing
"Auto-mirror new starred repositories" toggle in GitHub settings. Together
they cover the full owned/starred matrix that the scheduler already
supports.
Plumbing: config-mapper.ts now round-trips autoMirror through the UI/DB
boundary, and ScheduleConfig in types/config.ts gets the matching field.
No schema or migration change — autoMirror was already in the zod schema.
The "Auto-mirror new starred repositories" checkbox in the GitHub settings
was a filter layered on top of scheduleConfig.autoMirror, which itself is
only settable via the AUTO_MIRROR_REPOS env var (no UI). So users who
checked the box saw their starred repos auto-imported but never mirrored.
Treat autoMirror and autoMirrorStarred as independent triggers in the
scheduler: autoMirror covers owned (and self-starred) repos, autoMirrorStarred
covers repos starred from other owners. Either flag on its own is enough
to enter the auto-mirror phase, and the filter scopes the work accordingly.
Also normalize the owner comparison to lowercase since GitHub usernames are
case-insensitive — previously a self-starred repo whose stored owner casing
differed from the configured owner would be misclassified as a third-party
star.
Behavior change worth flagging in release notes: anyone who currently has
the starred checkbox on (broken state) will start getting starred repos
mirrored on upgrade. AUTO_MIRROR_REPOS=true users see no change.
* fix: hoist migrateSucceeded above try so catch can update DB on failure (fixes#268)
`let migrateSucceeded` was declared inside the try block of
mirrorGithubRepoToGitea and mirrorGitHubRepoToGiteaOrg, but the catch
block referenced it. Block-scoping made it invisible to catch, so any
error inside the try (network timeout, transient 5xx, etc.) crashed the
catch with `ReferenceError: migrateSucceeded is not defined` before
reaching the DB update that marks the repo "failed". Result: repos
stuck in "mirroring" forever with no entry in the activity log.
Hoisting the declaration above the try restores the intended behavior:
catch updates the repo to failed, clears mirroredLocation when migrate
hadn't succeeded, writes a failed activity-log entry, and re-throws
with the original error message preserved.
TypeScript was flagging this as "Cannot find name 'migrateSucceeded'"
but esbuild stripped the types during build, so the bug shipped.
* test: replace integration test with structural source check (#268)
The behavioral version of this regression test passed locally but
failed in CI because of mock.module pollution between files: bun's
mock.module is process-wide, so my mock for @/lib/gitea-enhanced
leaked into gitea-enhanced.test.ts (its real-module assertions saw
my null-returning mocks), and gitea-enhanced.test.ts's own
@/lib/http-client mock could supersede mine depending on file
discovery order, causing my mirrorGithubRepoToGitea call to not
throw at all in CI.
Replace with a structural assertion that reads gitea.ts and verifies
`let migrateSucceeded` is declared before the outermost try in both
mirrorGithubRepoToGitea and mirrorGitHubRepoToGiteaOrg. Verified the
new test fails on the pre-fix source with a clear error message
pointing to issue #268, and passes on the fixed source.
Forgejo < 15.0.0 silently discards auth_username/auth_password sent to
/api/v1/repos/migrate, causing subsequent pull-mirror sync of private repos
to fail with `terminal prompts disabled`. Fix landed upstream in Forgejo
v15.0.0 via codeberg.org/forgejo/forgejo/pulls/11909 and was not backported
to v12/v13/v14.
Test-connection endpoint now also probes /api/v1/version, detects Forgejo
via the `+gitea-` suffix, and surfaces a warning Alert in the Gitea config
form when the connected server reports a major version below 15.
The useConfigStatus hook treated `githubConfig.username` and
`giteaConfig.username` as required for the dashboard to render. In
practice neither is required at runtime — the GitHub token is
self-authenticating via listForAuthenticatedUser, and a Gitea username
isn't needed under single-org or flat mirror strategies.
Users who configured via env vars without GITHUB_USERNAME / GITEA_USERNAME
set (or who left those blank in the form, which is only client-side
`required`) ended up with empty strings in their config row. Mirroring
ran fine — tokens alone are sufficient — but the dashboard refused to
fetch and rendered all zeros because useConfigStatus failed the gate.
Drop the username checks from the gate. The `githubOwner` field is still
exported for consumers that want to display an owner; only the gate is
relaxed. Cache-hit and fresh-fetch branches both updated.
CI was on 1.3.10 while the Dockerfile runtime moved to 1.3.12 in v3.15.2,
so we were testing against an older runtime than we shipped. Align both
on 1.3.13 (latest stable). May also resolve the intermittent --coverage
instrumentation flake observed on 1.3.10 against http-client.ts.
Multiple "select from configs where userId" queries had no ORDER BY,
so when a user's database accidentally contained more than one config
row for the same user (e.g. from an env-loader insert path or a partial
default-config create), SQLite returned a non-deterministic row.
In the reported case this caused /api/config to hand back an empty stub
while /api/dashboard's repo/org counts came from the populated active
row. The dashboard's useConfigStatus hook then saw missing username/
token, treated config as incomplete, and never fetched dashboard data —
the UI rendered with all zeros even though 868 repos were sitting in
the database, mirroring fine in the background.
Add `ORDER BY isActive DESC, updatedAt DESC` before LIMIT 1 to every
"fetch the user's config" query so the active and most-recently-updated
row consistently wins. Also order env-config-loader's first-user pick
by createdAt for deterministic behavior across restarts.
Already-safe call sites that explicitly filter on isActive=true or
iterate all active configs (cleanup/scheduler/repositories/orgs/cleanup
trigger/sync-organization) are left unchanged.
Updates the mirror-repo test mock to match the new orderBy().limit()
chain.
Closes#271
* fix: honor GH_API_URL across all Octokit call sites
Six Octokit call sites constructed `new Octokit(...)` directly instead of
going through `createGitHubClient()`, so `GH_API_URL` (and the
`GITHUB_API_URL` fallback) only applied to the handful of flows that used
the helper. For GHES / GHEC-with-data-residency users this surfaced most
visibly as the "Test Connection" button hitting `api.github.com/user`
and failing with 401 even when `GH_API_URL` was set correctly (#269).
Route everything through `createGitHubClient()`:
- src/pages/api/github/test-connection.ts (the reported failure)
- src/pages/api/sync/repository.ts (public-repo sync)
- src/lib/gitea-enhanced.ts (force-push detection + metadata octokit)
- src/lib/scheduler-service.ts (auto-discovery, auto-mirror, auto-start)
- src/tests/test-metadata-mirroring.ts (dev harness, for consistency)
Side benefit: scheduler + sync paths now also get throttling, rate-limit
tracking, and the standard User-Agent, which they were missing.
`createGitHubClient`'s `token` parameter is made optional so the
public-repo sync path (`new Octokit()` with no auth) can keep working.
Fixes#269
* fix: address review findings
- scheduler: pass config.githubConfig?.owner (the real DB field) instead
of ?.username, which doesn't exist on the DB row and was silently
resolving to undefined — matches every other DB-reading call site.
- sync/repository.ts: revert to bare Octokit for the unauthenticated
public-repo lookup to preserve fast-fail on the 60 req/hr limit.
Still reads GH_API_URL / GITHUB_API_URL inline so GHES / GHEC
data-residency users benefit. The throttling plugin's retry-with-
backoff is wrong UX for a one-shot button click.
- github.ts: revert createGitHubClient token back to required (no
remaining callers pass undefined after the above).
- gitea-enhanced.ts: make the leftover Octokit import type-only.
- test-connection.test.ts: replace mid-test mock.module re-call with a
mutable stub reference — safer against ESM live-binding semantics.
- README + env reference + .env.example now cover using GH_API_URL to
target GitHub Enterprise Server or GHEC with data residency.
- Env reference + .env.example now cover SERVER_CERT_PATH and
SERVER_KEY_PATH, which @astrojs/node reads at runtime to terminate
TLS directly without a reverse proxy.
Closes#269Closes#272
- Add metadata state guards for issues/PRs in syncGiteaRepoEnhanced to
prevent re-mirroring on every sync (fixes#262)
- Fix httpPut → httpPatch for Gitea release updates (HTTP 405 error)
- Add paginated release retention cleanup to enforce releaseLimit by
deleting the oldest excess releases (fixes#264)
- Always send auth credentials during migration for all repos, not just
private ones, preventing "terminal prompts disabled" on Forgejo (fixes#263)
- Add missing service: "git" to org migration payload
- Make buildGithubSourceAuthPayload return {} instead of throwing when
token is missing, allowing unconditional use
- Preserve existing backup settings in env-config-loader so UI-configured
backup strategy and retention values survive restart (fixes#267)
Closes#262, #263, #264, #267
Update README, ENVIRONMENT_VARIABLES.md, and advanced docs page to
explicitly state that BETTER_AUTH_URL and PUBLIC_BETTER_AUTH_URL must be
origin only (scheme + host). The BASE_URL path prefix is applied
automatically — any path accidentally included is stripped.
The Nix build has been failing since v3.9.6 because bun.nix fell out
of sync with bun.lock. During the sandboxed build bun install cannot
fetch missing packages, causing ConnectionRefused errors.
- Add bun2nix regeneration step before nix build in CI
- Trigger workflow on bun.lock and package.json changes
- Update flake.nix version from 3.9.6 to 3.14.1
* feat: add custom sync start time scheduling
* Updated UI
* docs: add updated issue 240 UI screenshot
* fix: improve schedule UI with client-side next run calc and timezone handling
- Compute next scheduled run client-side via useMemo to avoid permanent
"Calculating..." state when server hasn't set nextRun yet
- Default to browser timezone when enabling syncing (not UTC)
- Show actual saved timezone in badge, use it consistently in all handlers
- Match time input background to select trigger in dark mode
- Add clock icon to time picker with hidden native indicator
* feat: add notification system with Ntfy.sh and Apprise providers (#231)
Add push notification support for mirror job events with two providers:
- Ntfy.sh: direct HTTP POST to ntfy topics with priority/tag support
- Apprise API: aggregator gateway supporting 100+ notification services
Includes database migration (0010), settings UI tab, test endpoint,
auto-save integration, token encryption, and comprehensive tests.
Notifications are fire-and-forget and never block the mirror flow.
* fix: address review findings for notification system
- Fix silent catch in GET handler that returned ciphertext to UI,
causing double-encryption on next save. Now clears token to ""
on decryption failure instead.
- Add Zod schema validation to test notification endpoint, following
project API route pattern guidelines.
- Mark notifyOnNewRepo toggle as "coming soon" with disabled state,
since the backend doesn't yet emit new_repo events. The schema
and type support is in place for when it's implemented.
* fix notification gating and config validation
* trim sync notification details
* fix: improve reverse proxy support for subdomain deployments (#63)
- Add X-Accel-Buffering: no header to SSE endpoint to prevent Nginx
from buffering the event stream
- Auto-detect trusted origin from Host/X-Forwarded-* request headers
so the app works behind a proxy without manual env var configuration
- Add prominent reverse proxy documentation to advanced docs page
explaining BETTER_AUTH_URL, PUBLIC_BETTER_AUTH_URL, and
BETTER_AUTH_TRUSTED_ORIGINS are mandatory for proxy deployments
- Add reverse proxy env var comments and entries to both
docker-compose.yml and docker-compose.alt.yml
- Add dedicated reverse proxy configuration section to .env.example
* fix: address review findings for reverse proxy origin detection
- Fix x-forwarded-proto multi-value handling: take first value only
and validate it is "http" or "https" before using
- Update comment to accurately describe auto-detection scope: helps
with per-request CSRF checks but not callback URL validation
- Restore startup logging of static trusted origins for debugging
* fix: handle multi-value x-forwarded-host in chained proxy setups
x-forwarded-host can be comma-separated (e.g. "proxy1.example.com,
proxy2.example.com") in chained proxy setups. Take only the first
value, matching the same handling already applied to x-forwarded-proto.
* test: add unit tests for reverse proxy origin detection
Extract resolveTrustedOrigins into a testable exported function and
add 11 tests covering:
- Default localhost origins
- BETTER_AUTH_URL and BETTER_AUTH_TRUSTED_ORIGINS env vars
- Invalid URL handling
- Auto-detection from x-forwarded-host + x-forwarded-proto
- Multi-value header handling (chained proxy setups)
- Invalid proto rejection (only http/https allowed)
- Deduplication
- Fallback to host header when x-forwarded-host absent
* fix: prevent starred repo name collisions during concurrent mirroring (#95)
When multiple starred repos share the same short name (e.g. alice/dotfiles
and bob/dotfiles), concurrent batch mirroring could cause 409 Conflict
errors because generateUniqueRepoName only checked Gitea via HTTP, missing
repos that were claimed in the local DB but not yet created remotely.
Three fixes:
- Add DB-level check in generateUniqueRepoName so it queries the local
repositories table for existing mirroredLocation claims, preventing two
concurrent jobs from picking the same target name.
- Clear mirroredLocation on failed mirror so a failed repo doesn't falsely
hold a location that was never successfully created, which would block
retries and confuse the uniqueness check.
- Extract isMirroredLocationClaimedInDb helper for the DB lookup, using
ne() to exclude the current repo's own record from the collision check.
* fix: address review findings for starred repo name collision fix
- Make generateUniqueRepoName immediately claim name by writing
mirroredLocation to DB, closing the TOCTOU race window between
name selection and the later status="mirroring" DB update
- Add fullName validation guard (must contain "/")
- Make isMirroredLocationClaimedInDb fail-closed (return true on
DB error) to be conservative about preventing collisions
- Scope mirroredLocation clear on failure to starred repos only,
preserving it for non-starred repos that may have partially
created in Gitea and need the location for recovery
* fix: address P1/P2 review findings for starred repo name collision
P1a: Remove early name claiming from generateUniqueRepoName to prevent
stale claims on early return paths. The function now only checks
availability — the actual claim happens at the status="mirroring" DB
write (after both idempotency checks), which is protected by a new
unique partial index.
P1b: Add unique partial index on (userId, mirroredLocation) WHERE
mirroredLocation != '' via migration 0010. This enforces atomicity at
the DB level: if two concurrent workers try to claim the same name,
the second gets a constraint violation rather than silently colliding.
P2: Only clear mirroredLocation on failure if the Gitea migrate call
itself failed (migrateSucceeded flag). If migrate succeeded but
metadata mirroring failed, preserve the location since the repo
physically exists in Gitea and we need it for recovery/retry.
* fix: prevent excessive disk usage from repo backups (#234)
Legacy configs with backupBeforeSync: true but no explicit backupStrategy
silently resolved to "always", creating full git bundles on every sync
cycle. This caused repo-backups to grow to 17GB+ for users with many
repositories.
Changes:
- Fix resolveBackupStrategy to map backupBeforeSync: true → "on-force-push"
instead of "always", so legacy configs only backup when force-push is detected
- Fix config mapper to always set backupStrategy explicitly ("on-force-push")
preventing the backward-compat fallback from triggering
- Lower default backupRetentionCount from 20 to 5 bundles per repo
- Add time-based retention (backupRetentionDays, default 30 days) alongside
count-based retention, with safety net to always keep at least 1 bundle
- Add "high disk usage" warning on "Always Backup" UI option
- Update docs and tests to reflect new defaults and behavior
* fix: preserve legacy backupBeforeSync:false on UI round-trip and expose retention days
P1: mapDbToUiConfig now checks backupBeforeSync === false before
defaulting backupStrategy, preventing legacy "disabled" configs from
silently becoming "on-force-push" after any auto-save round-trip.
P3: Added "Snapshot retention days" input field to the backup settings
UI, matching the documented setting in FORCE_PUSH_PROTECTION.md.
SQLite rejects ALTER TABLE ADD COLUMN with expression defaults like
DEFAULT (unixepoch()), which Drizzle-kit generated for the imported_at
column. This broke upgrades from v3.12.x to v3.13.0 (#228, #229).
Changes:
- Rewrite migration 0009 using table-recreation pattern (CREATE, INSERT
SELECT, DROP, RENAME) instead of ALTER TABLE
- Add migration validation script with SQLite-specific lint rules that
catch known invalid patterns before they ship
- Add upgrade-path testing with seeded data and verification fixtures
- Add runtime repair for users whose migration record may be stale
- Add explicit migration validation step to CI workflow
Fixes#228Fixes#229
The git-lfs go.mod contains a `toolchain go1.25.3` directive which
causes Go to auto-download and use Go 1.25.3 instead of our installed
1.25.8. Set GOTOOLCHAIN=local to force using the installed version.
Also update golang.org/x/crypto to latest before building to resolve
CVE-2025-47913 (needs >= 0.43.0, was pinned at 0.36.0).
Git-lfs v3.7.1 pre-built binaries use Go 1.25.3, which is affected by
CVE-2025-68121 (critical), CVE-2026-27142, CVE-2026-25679, CVE-2025-61729,
CVE-2025-61726, and CVE-2025-47913 (golang.org/x/crypto).
Since no newer git-lfs release exists, compile from source in a dedicated
build stage using Go 1.25.8 (latest patched release). Only the final
binary is copied into the runner image.
Bun 1.3.9 crashes with a segfault on CPUs without AVX support due to a
WASM IPInt bug (oven-sh/bun#27340), fixed in 1.3.10 via oven-sh/bun#26922.
- Bump Bun from 1.3.9 to 1.3.10 in Dockerfile, CI workflows, and packageManager
- Skip env config script when no GitHub/Gitea env vars are set
- Make startup scripts (env-config, recovery, repair) fault-tolerant so
a crash in a non-critical script doesn't abort the entrypoint via set -e
* feat: add autoMirrorStarred toggle for selective starred repo mirroring (#205)
Add `githubConfig.autoMirrorStarred` (default: false) to control whether
starred repos are included in automatic mirroring operations. Manual
per-repo actions always work regardless of this toggle.
Bug fixes:
- Cleanup service no longer orphans starred repos when includeStarred is
disabled (prevents data loss)
- First-boot auto-start now gates initial mirror behind autoMirror config
(previously mirrored everything unconditionally)
- "Mirror All" button now respects autoMirrorStarred setting
- Bulk mirror and getAvailableActions now include pending-approval status
Changes span schema, config mapping, env loader, scheduler, cleanup
service, UI settings toggle, and repository components.
* fix: log activity when repos are auto-imported during scheduled sync
Auto-discovered repositories (including newly starred ones) were inserted
into the database without creating activity log entries, so they appeared
in the dashboard but not in the activity log.
* ci: set 10-minute timeout on all CI jobs
Add www/** to paths-ignore in astro-build-test, e2e-tests, and
nix-build workflows. docker-build and helm-test already use positive
path filters and were unaffected.
- Update softwareVersion from 3.9.2 to 3.11.0
- Add Helm and Nix installation tabs to Getting Started section
- Fix Helm instructions to use local chart path (no published repo)
- Update Features section: add Metadata Preservation, Force-Push Protection, Git LFS Support
- Remove unused @radix-ui/react-icons import from Hero.tsx and dependency from package.json
- Update structured data featureList with newer capabilities
* feat: smart force-push protection with backup strategies (#187)
Replace blunt `backupBeforeSync` boolean with `backupStrategy` enum
offering four modes: disabled, always, on-force-push (default), and
block-on-force-push. This dramatically reduces backup storage for large
mirror collections by only creating snapshots when force-pushes are
actually detected.
Detection works by comparing branch SHAs between Gitea and GitHub APIs
before each sync — no git cloning required. Fail-open design ensures
detection errors never block sync.
Key changes:
- Add force-push detection module (branch SHA comparison via APIs)
- Add backup strategy resolver with backward-compat migration
- Add pending-approval repo status with approve/dismiss UI + API
- Add block-on-force-push mode requiring manual approval
- Fix checkAncestry to only treat 404 as confirmed force-push
(transient errors skip branch instead of false-positive blocking)
- Fix approve-sync to bypass detection gate (skipForcePushDetection)
- Fix backup execution to not be hard-gated by deprecated flag
- Persist backupStrategy through config-mapper round-trip
* fix: resolve four bugs in smart force-push protection
P0: Approve flow re-blocks itself — approve-sync now calls
syncGiteaRepoEnhanced with skipForcePushDetection: true so the
detection+block gate is bypassed on approved syncs.
P1: backupStrategy not persisted — added to both directions of the
config-mapper. Don't inject a default in the mapper; let
resolveBackupStrategy handle fallback so legacy backupBeforeSync
still works for E2E tests and existing configs.
P1: Backup hard-gated by deprecated backupBeforeSync — added force
flag to createPreSyncBundleBackup; strategy-driven callers and
approve-sync pass force: true to bypass the legacy guard.
P1: checkAncestry false positives — now only returns false for
404/422 (confirmed force-push). Transient errors (rate limits, 500s)
are rethrown so detectForcePush skips that branch (fail-open).
* test(e2e): migrate backup tests from backupBeforeSync to backupStrategy
Update E2E tests to use the new backupStrategy enum ("always",
"disabled") instead of the deprecated backupBeforeSync boolean.
* docs: add backup strategy UI screenshot
* refactor(ui): move Destructive Update Protection to GitHub config tab
Relocates the backup strategy section from GiteaConfigForm to
GitHubConfigForm since it protects against GitHub-side force-pushes.
Adds ShieldAlert icon to match other section header patterns.
* docs: add force-push protection documentation and Beta badge
Add docs/FORCE_PUSH_PROTECTION.md covering detection mechanism,
backup strategies, API usage, and troubleshooting. Link it from
README features list and support section. Mark the feature as Beta
in the UI with an outline badge.
* fix(ui): match Beta badge style to Git LFS badge
* fix(nix): ensure absolute bundle path in pre-sync backup (#203)
Use path.resolve() instead of conditional path.isAbsolute() check to
guarantee bundlePath is always absolute before passing to git -C. On
NixOS, relative paths were interpreted relative to the temp mirror
clone directory, causing "No such file or directory" errors.
Closes#203
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(nix): ensure absolute bundle path in pre-sync backup (#203)
Use path.resolve() instead of conditional path.isAbsolute() check to
guarantee bundlePath is always absolute before passing to git -C. On
NixOS, relative paths were interpreted relative to the temp mirror
clone directory, causing "No such file or directory" errors.
Extract resolveBackupPaths() for testability. Bump version to 3.10.1.
Closes#203
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* ci: drop macos matrix and only run nix build on main/tags
- Remove macos-latest from Nix CI matrix (ubuntu-only)
- Only run `nix build` on main branch and version tags, skip on PRs
- `nix flake check` still runs on all PRs for validation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add target organization field to Add Repository dialog
Allow users to specify a destination Gitea organization when adding a
single repository, instead of relying solely on the default mirror
strategy. The field is optional — when left empty, the existing strategy
logic applies as before.
Closes#200
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: add screenshot of target organization field in Add Repository dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add E2E testing infrastructure with fake GitHub, Playwright, and CI workflow
- Add fake GitHub API server (tests/e2e/fake-github-server.ts) with
management API for seeding test data
- Add Playwright E2E test suite covering full mirror workflow:
service health checks, user registration, config, sync, verify
- Add Docker Compose for E2E Gitea instance
- Add orchestrator script (run-e2e.sh) with cleanup
- Add GitHub Actions workflow (e2e-tests.yml) with Gitea service container
- Make GITHUB_API_URL configurable via env var for testing
- Add npm scripts: test:e2e, test:e2e:ci, test:e2e:keep, test:e2e:cleanup
* feat: add real git repos + backup config testing to E2E suite
- Create programmatic test git repos (create-test-repos.ts) with real
commits, branches (main, develop, feature/*), and tags (v1.0.0, v1.1.0)
- Add git-server container to docker-compose serving bare repos via
dumb HTTP protocol so Gitea can actually clone them
- Update fake GitHub server to emit reachable clone_url fields pointing
to the git-server container (configurable via GIT_SERVER_URL env var)
- Add management endpoint POST /___mgmt/set-clone-url for runtime config
- Update E2E spec with real mirroring verification:
* Verify repos appear in Gitea with actual content
* Check branches, tags, commits, file content
* Verify 4/4 repos mirrored successfully
- Add backup configuration test suite:
* Enable/disable backupBeforeSync config
* Toggle blockSyncOnBackupFailure
* Trigger re-sync with backup enabled and verify activities
* Verify config persistence across changes
- Update CI workflow to use docker compose (not service containers)
matching the local run-e2e.sh approach
- Update cleanup.sh for git-repos directory and git-server port
- All 22 tests passing with real git content verification
* refactor: split E2E tests into focused files + add force-push tests
Split the monolithic e2e.spec.ts (1335 lines) into 5 focused spec files
and a shared helpers module:
helpers.ts — constants, GiteaAPI, auth, saveConfig, utilities
01-health.spec.ts — service health checks (4 tests)
02-mirror-workflow.spec.ts — full first-mirror journey (8 tests)
03-backup.spec.ts — backup config toggling (6 tests)
04-force-push.spec.ts — force-push simulation & backup verification (9 tests)
05-sync-verification.spec.ts — dynamic repos, content integrity, reset (5 tests)
The force-push tests are the critical addition:
F0: Record original state (commit SHAs, file content)
F1: Rewrite source repo history (simulate force-push)
F2: Sync to Gitea WITHOUT backup
F3: Verify data loss — LICENSE file gone, README overwritten
F4: Restore source, re-mirror to clean state
F5: Enable backup, force-push again, sync through app
F6: Verify Gitea reflects the force-push
F7: Verify backup system was invoked (snapshot activities logged)
F8: Restore source repo for subsequent tests
Also added to helpers.ts:
- GiteaAPI.getBranch(), .getCommit(), .triggerMirrorSync()
- getRepositoryIds(), triggerMirrorJobs(), triggerSyncRepo()
All 32 tests passing.
* Try to fix actions
* Try to fix the other action
* Add debug info to check why e2e action is failing
* More debug info
* Even more debug info
* E2E fix attempt #1
* E2E fix attempt #2
* more debug again
* E2E fix attempt #3
* E2E fix attempt #4
* Remove a bunch of debug info
* Hopefully fix backup bug
* Force backups to succeed
* fix(nix): enable sandboxed builds with bun2nix
The Nix package was broken on Linux because `bun install` requires
network access, which is blocked by Nix sandboxing (enabled by default
on Linux).
This switches to bun2nix for dependency management:
- Add bun2nix flake input to pre-fetch all npm dependencies
- Generate bun.nix lockfile for reproducible dependency resolution
- Copy bun cache to writable location during build to avoid EACCES
errors from bunx writing to the read-only Nix store
- Add nanoid as an explicit dependency (was imported directly but only
available as a transitive dep, which breaks with isolated linker)
- Update CI workflow to perform a full sandboxed build
- Add bun2nix to devShell for easy lockfile regeneration
Closes#197
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(nix): create writable workdir for database access
The app uses process.cwd()/data for the database path, but when running
from the Nix store the cwd is read-only. Create a writable working
directory with symlinks to app files and a real data directory.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The runner stage was missing git, causing pre-sync backups to fail with
"Executable not found in $PATH: git". The backup feature (enabled by
default) shells out to git for clone --mirror and bundle create.
Closes#196
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
bun install requires network access which Nix sandbox blocks.
CI now validates flake structure and evaluates expressions only.
Full builds work locally with: nix build --option sandbox false
- Use DeterminateSystems/nix-installer-action for Nix installation
- Use DeterminateSystems/magic-nix-cache-action for caching (free, no setup)
- Update documentation to remove Cachix references
- Add nix branch to CI triggers
- Add 1-second delays between release creations to ensure distinct timestamps
- Prepend GitHub original publication date to release notes
- Improve logging to show chronological processing order
- Addresses Gitea API limitation where created_unix is always set to current time
Fixes#129
Fixes#141
The repository metadata field was missing from the database schema, which
caused the metadata sync state (issues, PRs, releases, etc.) to not persist.
This resulted in duplicate issues being created every time a repository was
synced because the system couldn't track what had already been mirrored.
Changes:
- Added metadata text field to repositories table in schema
- Added metadata field to repositorySchema Zod validation
- Generated database migration 0008_serious_thena.sql
Root cause analysis:
1. Code tried to read/write repository.metadata to track mirrored components
2. The metadata field didn't exist in the database schema
3. On sync, metadataState.components.issues was always false
4. This triggered re-mirroring of all issues, creating duplicates
The fix ensures metadata state persists between mirrors and syncs, preventing
duplicate metadata (issues, PRs, releases) from being created in Gitea.
Updated development documentation to reflect current project structure
and simplified setup process.
Changes:
- DEVELOPMENT_WORKFLOW.md: Updated repository URL, simplified setup steps,
improved project structure documentation, and clarified command descriptions
- README.md: Reorganized as a concise index of available guides, removed
redundant content now covered in main README and in-app help
- SHUTDOWN_PROCESS.md: Removed (content consolidated into GRACEFUL_SHUTDOWN.md)
These updates make the documentation more accurate and easier to navigate
for new contributors.
Updated the vendor lock-in prevention article with more accurate UI navigation
paths and technical details to match current application features.
Changes:
- Added missing title heading
- Updated navigation instructions to reference actual UI paths (Configuration → Connections, Content & Data, Automation)
- Improved technical accuracy of sync interval recommendations
- Added FAQ section with practical questions about auto-discovery, sync intervals, and cutover scripting
- Clarified activity log and API endpoint usage for monitoring
These changes ensure the article reflects the current application UI and features.
Updated Helm chart configuration to use the correct application port (4321)
instead of the incorrect default (8080). This aligns the Helm deployment
with the actual application configuration.
Changes:
- helm/gitea-mirror/values.yaml: Updated service.port and deployment.port to 4321
- helm/gitea-mirror/README.md: Updated all port references in documentation
- www/src/pages/use-cases/deploy-with-helm-chart.mdx: Fixed article to reflect
correct port, added GitHub links to Helm chart, and improved installation instructions
The application runs on port 4321 as defined in:
- Dockerfile (ENV PORT=4321, EXPOSE 4321)
- docker-compose.yml (4321:4321 mapping)
- .env.example (PORT=4321)
Tested with local Kubernetes cluster and confirmed the application is accessible
on port 4321.
Since githubConfig is stored as JSON in the database (not individual columns),
no database migration is needed. However, we need to handle reading old configs
that still use the 'skipStarredIssues' field name.
Changes:
- Added skipStarredIssues as optional field in Zod schema (marked deprecated)
- Updated config mapper to check both starredCodeOnly and skipStarredIssues
- Old configs will continue to work seamlessly
- New configs will use starredCodeOnly field name
This ensures zero-downtime upgrades for existing installations.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
The previous name 'skipStarredIssues' was misleading as it now skips ALL
metadata (not just issues) for starred repositories. The new name
'starredCodeOnly' better reflects the actual behavior - mirroring only
source code for starred repos.
Changes:
- Renamed skipStarredIssues → starredCodeOnly in all files
- Updated UI label from "Don't fetch issues" to "Code-only mode"
- Updated description to clarify it skips ALL metadata types:
issues, PRs, labels, milestones, wiki, and releases
- Updated database schema, types, config mapper, and all components
- Updated Helm charts, CI configs, and documentation
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Extended the skipStarredIssues flag to also skip releases for starred repos
when "code-only" mode is enabled. Previously, releases were still being
mirrored even when lightweight mode was selected.
Now starred repos with "code-only" mode will skip:
- Issues ✓
- Pull requests ✓
- Labels ✓
- Milestones ✓
- Wiki ✓
- Releases ✓ (this fix)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Previously, the skipStarredIssues flag (Lightweight mode for starred repos)
only applied to issues. This caused starred repos to mirror all metadata
(pull requests, labels, milestones, wiki) even when "code-only" was selected.
Fixed by applying the skipStarredIssues check to:
- Pull requests mirroring
- Labels mirroring
- Milestones mirroring
- Wiki mirroring (in migration payload)
Now starred repos with "code-only" mode truly mirror only source code,
skipping all metadata as intended.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
- Added docker-entrypoint.sh to trigger paths
- Added drizzle/** for database migrations
- Added scripts/** for database management scripts
- Added src/** for source code changes
This ensures Docker images are rebuilt when critical runtime
files change, not just package dependencies.
## Problem
Forgejo 12.0+ rejects migration API calls with credentials embedded in URLs,
causing HTTP 422 errors when mirroring private GitHub repositories.
## Root Cause
Breaking security change in Forgejo 12.0 (July 2025) enforces credential
separation to prevent accidental exposure in logs/errors. Previous versions
(Forgejo 11.x, Gitea 1.x) accepted embedded credentials.
## Solution
- Use separate `auth_username` and `auth_token` fields instead of embedding
credentials in clone URLs
- Set `auth_username` to "oauth2" for GitHub token authentication
- Pass GitHub token via `auth_token` field
## Changes
- src/lib/gitea.ts:
- mirrorGithubRepoToGitea(): Use separate auth fields for private repos
- mirrorGitHubRepoToGiteaOrg(): Use separate auth fields for private repos
- .github/workflows/docker-build.yml:
- Enable PR image building and pushing to GHCR
- Tag PR images as pr-<number> for easy testing
- Add automated PR comment with image details and testing instructions
- Separate load step for security scanning
## Backward Compatibility
✅ Works with Forgejo 12.0+
✅ Works with Forgejo 11.x and earlier
✅ Works with Gitea 1.x
## Testing
Public repos: ✅ Working (no auth needed)
Private repos: ✅ Fixed (separate auth fields)
Fixes#102
- Prevent Automation UI from overriding schedule:
- mapDbScheduleToUi now parses intervals robustly (cron/duration/seconds) via parseInterval
- mapUiScheduleToDb merges with existing config and stores interval as seconds (no lossy cron conversion)
- /api/config passes existing scheduleConfig to preserve ENV-sourced values
- schedule-sync endpoint uses parseInterval for nextRun calculation
- Add AUTO_MIRROR_REPOS support and scheduled auto-mirror phase:
- scheduleConfig schema includes autoImport and autoMirror
- env-config-loader reads AUTO_MIRROR_REPOS and carries through to DB
- scheduler auto-mirrors imported/pending/failed repos when autoMirror is enabled before regular sync
- docker-compose and ENV docs updated with AUTO_MIRROR_REPOS
- Tests pass and build succeeds
- Implemented comprehensive GitHub API rate limit handling:
- Integrated @octokit/plugin-throttling for automatic retry with exponential backoff
- Added RateLimitManager service to track and enforce rate limits
- Store rate limit status in database for persistence across restarts
- Automatic pause and resume when limits are exceeded
- Proper user identification for 5000 req/hr authenticated limit (vs 60 unauthenticated)
- Improved rate limit UI/UX:
- Removed intrusive rate limit card from dashboard
- Toast notifications only at critical thresholds (80% and 100% usage)
- All rate limit events logged for debugging
- Optimized for GitHub's API constraints:
- Reduced default batch size from 10 to 5 repositories
- Added documentation about GitHub's 100 concurrent request limit
- Better handling of repositories with many issues/PRs
- Add missing database fields (language, description, mirroredLocation, destinationOrg) to repository operations
- Add missing organization fields (publicRepositoryCount, privateRepositoryCount, forkRepositoryCount) to schema
- Update GitRepo interface to include all required database fields
- Fix GitHub data fetching functions to map all fields correctly
- Update all sync endpoints (main, repository, organization, scheduler) to handle new fields
This fixes the "SQLite query expected X values, received Y" error when importing
large numbers (4.6k+) of starred repositories by ensuring all database fields
are properly mapped from GitHub API responses through to database insertion.
Major fixes for Docker environment variable issues and cleanup functionality:
🔧 **Duration Parser & Scheduler Fixes**
- Add comprehensive duration parser supporting "8h", "30m", "24h" formats
- Fix GITEA_MIRROR_INTERVAL environment variable mapping to scheduler
- Auto-enable scheduler when GITEA_MIRROR_INTERVAL is set
- Improve scheduler logging to clarify timing behavior (from last run, not startup)
🧹 **Repository Cleanup Service**
- Complete repository cleanup service for orphaned repos (unstarred, deleted)
- Fix cleanup configuration logic - now works with CLEANUP_DELETE_IF_NOT_IN_GITHUB=true
- Auto-enable cleanup when deleteIfNotInGitHub is enabled
- Add manual cleanup trigger API endpoint (/api/cleanup/trigger)
- Support archive/delete actions with dry-run mode and protected repos
🐛 **Environment Variable Integration**
- Fix scheduler not recognizing GITEA_MIRROR_INTERVAL=8h
- Fix cleanup requiring both CLEANUP_DELETE_FROM_GITEA and CLEANUP_DELETE_IF_NOT_IN_GITHUB
- Auto-enable services when relevant environment variables are set
- Better error logging and debugging information
📚 **Documentation Updates**
- Update .env.example with auto-enabling behavior notes
- Update ENVIRONMENT_VARIABLES.md with clarified functionality
- Add comprehensive tests for duration parsing
This resolves the core issues where:
1. GITEA_MIRROR_INTERVAL=8h was not working for automatic mirroring
2. Repository cleanup was not working despite CLEANUP_DELETE_IF_NOT_IN_GITHUB=true
3. Users had no visibility into why scheduling/cleanup wasn't working
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
### Fixed
- Added missing release asset mirroring functionality (APK, ZIP, Binary files)
- Release assets (attachments) are now properly downloaded from GitHub and uploaded to Gitea
- Fixed missing metadata component configuration checks
### Added
- Full support for mirroring release assets/attachments
- Debug logging for metadata component configuration to help troubleshoot mirroring issues
- Download and upload progress logging for release assets
### Improved
- Enhanced release mirroring to include all associated binary files and attachments
- Better visibility into which metadata components are enabled/disabled
- More detailed logging during the release asset transfer process
Fixes#68
Co-Authored-By: Claude <noreply@anthropic.com>
This patch completes the authentication fixes from v3.2.4, specifically addressing the releases mirroring function that was missed in the previous update.
Fixes:
- Critical authentication error in releases mirroring (encrypted token usage)
- Missing repository existence verification for releases
- "user does not exist [uid: 0]" error for GitHub releases sync
Improvements:
- Duplicate release detection to prevent errors
- Better error handling with per-release fault tolerance
- Enhanced logging with [Releases] prefix for debugging
Issue: #68
Fixed critical authentication issue causing "user does not exist [uid: 0]" errors during metadata mirroring operations. This release addresses Issue #68 and ensures proper authentication validation before all Gitea operations.
Key improvements:
- Pre-flight authentication validation for all Gitea operations
- Consistent token decryption across all API calls
- Repository existence verification before metadata operations
- Graceful fallback to user account when org creation fails
- Enhanced error messages with specific troubleshooting guidance
- Added diagnostic test scripts for authentication validation
This patch ensures metadata mirroring (issues, PRs, labels, milestones) works reliably without authentication errors.
- Added support for 60+ environment variables covering all configuration options
- Created detailed documentation in docs/ENVIRONMENT_VARIABLES.md with tables
- Fixed missing skipStarredIssues field in GitHub config
- Updated docker-compose files to reference environment variable documentation
- Updated README to link to the new environment variables documentation
- Environment variables now populate UI configuration automatically on Docker startup
- Preserves manual UI changes when environment variables are not set
- Includes support for mirror metadata, scheduling, cleanup, and authentication options
Fixes#69🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
- Add http-client mocks to gitea-enhanced.test.ts for proper isolation
- Fix GiteaRepoInfo interface to handle owner as object or string
- Add gitea module mocks to gitea-starred-repos.test.ts
- Update test expectations to match actual function behavior
- Fix handleExistingNonMirrorRepo to properly extract owner from repoInfo
These changes ensure tests pass consistently in both local and CI environments
by properly mocking all dependencies and handling API response variations.
- Add config-encryption module mocks to gitea-enhanced.test.ts
- Add config-encryption module mocks to gitea-starred-repos.test.ts
- Update helpers mock in setup.bun.ts to include createEvent function
The CI environment was loading modules in a different order than local,
causing the config-encryption module to be accessed before it was mocked
in the global setup. Adding the mocks directly to the test files ensures
they are available regardless of module loading order.
- Update Bun version in CI to match local version (1.2.16)
- Add bunfig.toml with 5s test timeout to prevent hanging tests
- Mock setTimeout globally in test setup to avoid timing issues
- Add NODE_ENV check to skip delays during tests
- Fix missing exports in config-encryption mock
- Remove retryDelay in tests to ensure immediate execution
These changes ensure tests run consistently between local and CI environments
### Added
- Comprehensive mobile and responsive design support across the entire application
- New drawer UI component for enhanced mobile navigation
- Mobile-specific layouts for major components
- Mobile screenshots in documentation
### Improved
- Enhanced mobile user experience with optimized layouts
- Updated organization list cards with better mobile responsiveness
- Better touch interaction support throughout the application
### Fixed
- Type definition issues resolved
- Removed unnecessary console.log statements
### Documentation
- Updated README with mobile usage instructions and screenshots
- Added mobile-specific documentation sections
- Update ActivityLog component for better mobile responsiveness
- Enhance Header layout for mobile devices
- Improve mobile UX in AddOrganizationDialog
- Optimize Organization component mobile display
- Enhance AddRepositoryDialog mobile layout
- Add support for custom CA certificates in Docker setup
- Two mounting options: individual certs or system CA bundle
- Automatic detection and configuration via NODE_EXTRA_CA_CERTS
- Enhanced documentation with setup guide in certs/README.md
- Added ca-certificates package to Alpine base image
- Updated docker-compose with clear volume mount examples
- Bump version to 2.21.0
- Fix mixed mode organization strategy not persisting after page refresh
- Update README to be more concise and use RayLabsHQ organization links
- Remove unimplemented OIDC/SSO authentication references
- Update dependencies to latest versions
- Bump @astrojs/node from 9.2.2 to 9.3.0
- Update @tailwindcss/vite from 4.1.10 to 4.1.11
- Upgrade @tanstack/react-virtual from 3.13.10 to 3.13.12
- Change astro version from 5.9.3 to 5.11.0
- Update lucide-react from 0.515.0 to 0.525.0
- Bump tailwindcss from 4.1.10 to 4.1.11
- Upgrade tw-animate-css from 1.3.4 to 1.3.5
- Update zod from 3.25.64 to 3.25.75
- Bump @types/jsonwebtoken from 9.0.9 to 9.0.10
- Upgrade @vitejs/plugin-react from 4.5.2 to 4.6.0
- Update vitest from 3.2.3 to 3.2.4
- Change packageManager from bun@1.2.16 to bun@1.2.18
Major repository migration from arunavo4 to RayLabsHQ organization.
This release includes updated Docker image locations and all repository
references throughout the codebase.
This workflow builds and pushes Docker images to GitHub Container Registry (ghcr.io), but only when changes are merged to the main branch.
This workflow builds Docker images on pushes and pull requests, scans them, and pushes to GitHub Container Registry (ghcr.io) when permissions allow (main/tags and same-repo PRs).
**When it runs:**
- On push to the main branch
- On tag creation (v*)
- On pull requests (build + scan; push only for same-repo PRs)
- Weekly on Sunday at midnight (scheduled security scan)
**Key features:**
- Builds multi-architecture images (amd64 and arm64)
- Creates multiple tags for each image (latest, semver, sha)
- Auto-syncs `package.json` version from `v*` tags during release builds
- Validates release tags use semver format before building
- After tag builds succeed, writes the same version back to `main/package.json`
### Docker Security Scan (`docker-scan.yml`)
This workflow scans Docker images for security vulnerabilities using Trivy.
**When it runs:**
- On push to the main branch that affects Docker-related files
- Weekly on Sunday at midnight (scheduled)
**Key features:**
- Scans for critical and high severity vulnerabilities
- Fails the build if vulnerabilities are found
- Ignores unfixed vulnerabilities
**Security scanning:**
- Docker Scout runs on main/tag pushes, the weekly schedule, and same-repo PRs. It needs the `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` secrets, posts a PR comment, and uploads SARIF to the Security tab.
- Trivy runs on every PR, including forks. It needs no credentials and writes critical/high (fixable) findings to the job summary.
- Fork PRs cannot use Docker Scout because GitHub never exposes repository secrets to them. Do not "fix" that with `pull_request_target`; it would run untrusted PR code with the secrets available.
- Both scanners are informational and do not fail the build.
## CI/CD Pipeline Philosophy
Our CI/CD pipeline follows these principles:
1.**Fast feedback for developers**: The Astro build and test workflow runs on all branches and PRs to provide quick feedback.
2.**Efficient resource usage**: Docker images are only built when changes are merged to main, not for every PR.
2.**Efficient resource usage**: Docker images are built for every PR so they can be scanned, but only pushed for main, tags, and same-repo PRs.
3.**Security first**: Regular security scanning ensures our Docker images are free from known vulnerabilities.
4.**Multi-architecture support**: All Docker images are built for both amd64 and arm64 architectures.
@@ -85,3 +83,10 @@ If a workflow fails:
- Security vulnerabilities
For persistent issues, consider opening an issue in the repository.
### Helm Test (`helm-test.yml`)
This workflow run on the main branch and pull requests. it:
| `util-linux-mount-nsenter.vex.json` | CVE-2026-78408, CVE-2026-78409, CVE-2026-78410 | util-linux 2.41.5-0+deb13u1 (trixie, no fix planned) | nsenter and setuid mount(8) are never used; the runner stage strips setuid bits and the app runs unprivileged. |
| `zlib-gz-vacate.vex.json` | CVE-2026-85091 | zlib 1:1.3.dfsg+really1.3.1-1 (trixie, unfixed in every Debian release) | The gz* write API (gzwrite, gzprintf) is never used: git, git-lfs and Bun only use the deflate and inflate stream functions. |
"impact_statement":"nsenter is never run inside this image. The application runs as the unprivileged gitea-mirror user and only executes bun, git and git-lfs. The --join-cgroup path needs a privileged operator to run nsenter against a target, which happens on the host with the host's own nsenter, not with the copy in this container. Debian rates the issue minor and ships no fix for trixie (util-linux 2.41.5-0+deb13u1); the fix is only in unstable (2.42.3-1)."
},
{
"vulnerability":{
"name":"CVE-2026-78409",
"description":"util-linux mount(8) X-mount.subdir follows intermediate symlinks on Linux 6.15 and later, letting an fstab-authorized user attach a host path."
"impact_statement":"The flaw needs a setuid mount(8) and an fstab entry with X-mount.subdir that authorizes an unprivileged user. The image ships no user-mountable fstab entries, the runner stage strips the setuid and setgid bit from every binary, and the application runs as an unprivileged user that never calls mount. Debian rates the issue minor and ships no fix for trixie."
},
{
"vulnerability":{
"name":"CVE-2026-78410",
"description":"util-linux mount(8) does not pin the source of a restricted bind mount before the privileged mount, so a user who can replace the source can redirect it."
"impact_statement":"The flaw needs a setuid mount(8) and an fstab bind entry an unprivileged user is allowed to mount. The image ships no such entries, the runner stage strips the setuid and setgid bit from every binary, and the application runs as an unprivileged user that never calls mount. Debian rates the issue minor and ships no fix for trixie."
"description":"zlib gz_vacate() heap buffer overflow: a non-blocking gzwrite() that stalls leaves a stale external buffer pointer, and a later gzprintf() or gzvprintf() moves more bytes than the internal input buffer holds."
"impact_statement":"The flaw sits in zlib's gz* file writing API (gzwrite, gzprintf, gzvprintf) and needs a gz stream opened for writing on a non-blocking descriptor that stalls. Nothing in this image writes gzip files through that API: git links only the deflate and inflate stream entry points for its objects and packs, git-lfs is a Go binary with its own compress/flate, and Bun's zlib bindings expose stream compression only. The application itself never opens gz streams. Debian ships zlib 1:1.3.dfsg+really1.3.1-1 in trixie and has no fix in any release (bug #1146895); upstream describes the affected range as 1.3.1.2 through 1.3.2."
@@ -7,6 +7,415 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Refresh sizes** on the Storage page measures every mirror now instead of waiting for its next sync, so the page fills in on a fresh install. It only reads the destination: one repository listing per destination owner, the single repository GET for a mirror the listing does not have, and the release listing of repositories that mirror releases. It runs in the background with its progress on the button (`POST /api/storage/refresh`)
- **Remove old release assets** brings the attachments of older releases under a release asset limit. Turning the limit on, or lowering it, used to leave every file uploaded earlier in place, so the limit only held for the future. With the switch on, a kept release past the newest N releases that get assets loses its attachments on the next sync; the release, its notes and its tag stay, and a release whose attachments cannot be listed is left alone. It is a global setting (`PRUNE_OLD_RELEASE_ASSETS`, off by default) because it deletes files from the destination, while the limit it works with stays configurable per organization and repository
### Changed
- Pages keep what they last showed in this browser tab. Going back to the Dashboard, Repositories, Organizations, Storage or Activity Log shows that data at once and refreshes it in the background, instead of starting from a skeleton and waiting for the whole list again. Nothing is written to browser storage; a reload or a new tab starts fresh
### Fixed
- The Activity Log came back as an error instead of the feed once a sync had finished with a warning. A run whose release filter matched nothing on the destination leaves a job whose status is `warning`, and the feed validated the status with the repository status list, which has no such value
- Long lists no longer put every row in the page. The Repositories page built a card for every repository even on desktop, where the cards are hidden, and the Storage table rendered every row; with 3,000 repositories the Repositories page held over 120,000 elements. Only the layout on screen is rendered now, and the mobile cards and Storage rows are virtualized like the desktop table, so a few hundred elements are on the page whatever the count
- The owner and organization filters on the Repositories page and the name filter on the Activity Log list names in alphabetical order regardless of case (#487). A plain sort put every capitalized name before the lowercase ones and accented names last
- Scrolling the Repositories table on desktop dropped frames on slower machines. Each scroll step rendered the filters and every visible row again, and each row coming into view forced a style recalculation for its name. Only the rows coming into view render now; with the CPU slowed 4x, a scroll frame went from about 31 ms to about 13 ms
- Destination release listings stopped after 30 releases. Gitea and Forgejo ignore `per_page` and answer 30 a page, and the listing stopped on any page shorter than the 100 it asked for. The release limit only trimmed within the newest 30 on each sync, the Storage page counted at most 30 releases per repository, and the release filter preview showed only the newest 30 tags. Listings now page with `limit` until the total the destination reports
- The sync button on an archived repository says **Sync** like every other row instead of **Manual Sync**, so the wider label no longer pushes the row's status badge out of line. Hovering it explains that automatic syncs are off for archived mirrors
- The Activity Log summary no longer shows a repository as syncing after a quick sync finished (#454). Events are stored to the second, and a sync that starts and finishes in the same second could come back in the wrong order, so the start event was counted as the current state. Activities now come back newest first within the same second too, and the summary counts a finish over a start from the same second
- Issue and pull request passes over large repositories finish across rate limit resets (#449 follow-up)
- A pass stopped by the rate limit keeps the numbers it finished, and the next run repeats the same pass and skips them unless they changed since it started; before, every run started over from the first item, so a repository too large for one hour of budget never completed a pass
- The weekly full pass no longer fetches comments for issues, or the detail, commits and files of pull requests, that are already in the destination and have not changed since the last completed pass; it lists everything and corrects title, body, state and labels from the listing
- A sync paused by the rate limit logs one line instead of the error object with every partial result, and its activity entry says it was paused
- No request leaves the process while GitHub is rate limited (#437, second report)
- The v3.37.0 change stopped the scheduler run from holding its lock through a rate limit, but every other request already queued in the run still went to GitHub and came back 403, and the retry helper sent each of them three more times; GitHub counts requests made while limited toward abuse detection and one account was suspended that way
- Every GitHub client now holds its requests while the source is paused: a limit that resets within two minutes is waited out inside the request, a longer one fails the request at once without contacting GitHub, and the rate limit probe endpoint stays available so the pause can end early
- The retry helper no longer retries a rate limit refusal and stops starting the remaining items of the batch
- A rate limit refusal inside the issues, pull requests, releases, labels or milestones pass now fails the repository (which the scheduler already puts back to its previous status and retries after the reset) instead of moving on to the next component, and a pull request whose detail call was refused is no longer written as a stripped down issue
- A secondary rate limit pauses the whole client for its retry-after window, not only the request that was told to slow down
### Security
- Source URLs go through the outbound guard too (GHSA-p7w3-46pg-mv6h): `POST /api/sources`, `PUT /api/sources/:id` and the configuration save refuse link local and metadata addresses for source and destination URLs, the source fetch helper pins and never follows redirects like the other user supplied URLs, source errors no longer carry the upstream body in their message, and errors raised by a request to another host are never forwarded to API clients whatever words they contain
- Fixed four privately reported vulnerabilities (GHSA-9m33-xfrc-5jxw, GHSA-6m23-28hh-gjh2, GHSA-2hpx-83vg-gm45, GHSA-5pp8-r7f5-6q8p)
-`POST /api/gitea/test-connection` and `POST /api/github/test-connection` now require a signed-in user; they were the only non-public API routes without a guard and made a server side request to any URL in the body
- The email sign-up endpoint is closed server side once an account exists (`AUTH_ALLOW_SIGNUP=true` reopens it); only the signup page redirected before, so anyone reaching the instance could create an account through the API
- SSO providers are scoped to the user who created them for listing, updating and deleting, and the OIDC client secret is no longer returned by the API (the form keeps the stored secret when the field is left blank)
- HTTP errors from Gitea and other hosts no longer carry the upstream response body in the message, so an error can no longer echo the content of a host the caller chose
- Requests to user supplied URLs (connection tests, OIDC discovery, ntfy, Gotify, Apprise and webhook notifications) refuse link local and cloud metadata addresses and no longer follow redirects; private networks stay allowed because mirroring to a LAN Gitea is the normal deployment
- Raised the zod floor to 4.6.7 in the application (GHSA-mrhx-c7xc-m3w5, memory exhaustion while validating an oversized array that has a length limit). Before 4.6.6 zod still checked every element of a list longer than its `.max()`; the MCP tools' repository id list is the one capped array that takes outside input, behind an API key. Astro's own nested zod 4.5.4 now resolves to the same 4.6.7
- Raised the devalue floor to 5.9.2 in the application and the documentation site (GHSA-9rgm-9g3h-6x36, denial of service through malformed input); both lockfiles resolve 5.9.4
- Raised the dependency floors for the advisories published on 2026-09-08: Astro 7.2.8 (remote code execution through AVIF image optimization, and an authorization bypass when stripping the configured base), @xmldom/xmldom 0.8.15 (eight parser and serializer issues), sharp 0.35.4 (libheif), svgo 4.1.0 (removeScripts sanitization) and js-yaml 4.3.2 (merge-key CPU use). Applied to both the application and the documentation site.
### Added
- A Storage page that shows how much space the mirrors take on the destination (#479)
- Cards for the total, Git + LFS, release assets and how many mirrors are measured, bars by destination owner and for the largest repositories, and a sortable table with search and an owner filter
- Sizes come from responses a sync already fetches: the destination repository lookup before `mirror-sync` (Git plus LFS) and the release listing the retention pass reads (release assets). No extra requests to the source or the destination
- A repository whose release assets are most of its size gets a hint that a lower release limit removes the oldest releases
- The same numbers are available from `GET /api/storage`
- Read only API keys
- **Create key** in the **API Keys** card has an **Access** choice, and read only keys show a badge in the list
- A read only key can call every `GET` endpoint; any `POST`, `PUT`, `PATCH` or `DELETE`, and every `/api/auth/*` call except `get-session`, gets `403` with the code `API_KEY_READ_ONLY`
- Existing keys keep full access
- An MCP endpoint at `/api/mcp` for Claude, Cursor and other MCP clients
- Takes an API key in `x-api-key`; clients that send headers to a remote server (Claude Code) connect to it directly
- Eight tools: `list_repositories`, `get_repository`, `list_failures`, `list_organizations`, `add_repository`, `add_organization`, `sync_repositories` and `retry_repositories`. None of them delete, archive or change settings
- The tools act through the REST API with the caller's key, so a read only key can look but every change is refused. `MCP_INTERNAL_URL` sets the address they use when the app is not reachable on its own `HOST` and `PORT`
-`gitea-mirror-mcp` in `mcp/` is a stdio bridge for clients that start a local command (Claude Desktop, Cursor). It has no dependencies and is published to npm from `mcp-v*` tags; setup is in `mcp/README.md` and on the documentation site
- Choose which releases count as candidates: every one, or stable releases only
- A **Pre-releases** switch sits under the two release numbers in the mirror settings, and the same choice is in the per organization and per repository **Mirror options** dialog, where **Inherit** means the global value
- The release controls in the global mirror settings are listed under each other instead of beside the switch, and in the **Mirror options** dialog the release limit, asset limit and tag rules sit directly under the **Releases** row instead of on a separate tab
- **Stable only** skips everything the source marks as a pre-release, and the default **All releases** keeps the previous behaviour
- The filter runs before the release limit and looks further back first, so "the newest 10" means the newest 10 matching releases rather than whatever survives of the newest 10
- The retention pass also removes destination releases the filter excludes; a release the destination reports no pre-release flag for is kept, so an unknown flag never deletes the wrong thing
-`RELEASE_TYPE=all|stable` seeds the global default, and no migration is needed because the value lives inside the existing JSON columns
- Filter mirrored releases by tag name with **Include tags** and **Exclude tags**
- **Custom tag rules...** opens a dialog holding the two lists, both in the mirror settings and in the per organization and per repository **Mirror options** dialog, where leaving both empty means the level above
- A pattern is a glob over the whole tag (`v*`, `v1.*`, `*-rc.*`, `?` for one character) or a regular expression in slashes (`/^v\d+\.\d+\.\d+$/`), matched case-insensitively; exclude wins when both lists name the same tag, and a pattern that cannot compile is ignored instead of failing the run. Commas inside the slashes belong to the expression (`/^v\d{1,3}\.\d+$/`), in the dialog and in the environment variables alike
- This is the filter for projects that ship release candidates without marking them as pre-releases, which the type filter cannot separate
- A tag filter widens the release scan like the type filter does, so the limit counts the releases that match, and the retention pass removes destination releases whose tag no longer matches, including ones the destination reports no pre-release flag for
- When the filters delete every release the destination had, the activity log shows a highlighted **warning** entry "All releases removed" and the **Sync errors** switch sends the same alert, so an over-broad rule cannot empty the destination silently
- The dialog lists the releases the destination already holds and crosses out the ones the draft rules would delete on the next sync, so a pattern can be checked before saving. Opened from a repository it previews that one; from an organization or the global settings it adds a picker over the repositories the rules cover
-`RELEASE_INCLUDE_TAGS` and `RELEASE_EXCLUDE_TAGS` seed the global lists; the values live in the existing JSON columns, so no migration is needed
- Notifications for newly discovered repositories
- "New repositories discovered" on the Notifications page, with three sub-switches under it: new repositories in your own account, in one of your organizations, and starred repositories
- The scheduled sync, the boot auto-start and Sync Organization send them once per group with the repositories that were imported; all stars share a single "Starred" alert because their owners are arbitrary, and a long list is cut to the first 10 names plus an "and N more" line
- The stored configuration gains `notifyOnNewOwnRepo`, `notifyOnNewOrgRepo` and `notifyOnNewStarredRepo` (default on) below the existing `notifyOnNewRepo` master switch; no migration is needed because they live inside the existing JSON column, and a row saved before them behaves as all three on
- List view for the Organizations page (#428)
- A cards/list switch in the toolbar; the list shows one row per organization with its role, destination, repository counts, last mirrored time, status, the same actions as the cards and the source and destination links, so a few dozen organizations fit on one screen
- The choice is remembered per browser, next to the theme and time format preferences
- Incremental issue and pull request sync (#449)
- After a complete pass, the issues and pull request passes ask GitHub only for items updated since the last one (with a 10 minute margin), so comments, pull request details, commits and files are fetched only for what changed instead of for every item on every sync
- The watermark is stored per repository in the metadata state and only moves when a pass finishes with no failed item
- A full pass still runs when the last one is older than 7 days, when the destination has no mirrored issues or pull requests, and after Reset metadata
- Releases mirror from Gitea and Forgejo sources, Codeberg included (#440)
- The release mirror lists releases through the repository's own source instead of always through the GitHub API, and keeps the same release limit, asset limit, per-destination lock, tag check and retention pruning
- Release assets are downloaded with the credentials of the host they live on, so a Gitea source uses its own token and a public one needs none
- The release switch and its two limits are enabled for Gitea and Forgejo sources; GitLab stays code only
- Sync an organization that is already mirrored (#429)
- The organization card menu gains Sync Organization for mirrored and failed organizations; the Mirror button only ever covered the first run
-`POST /api/job/sync-org` re-discovers the organization's repositories from its source, mirrors the imported ones and syncs the mirrored, synced and failed ones, leaving rows another run owns alone; the organization is claimed before the response so two clicks cannot both start a run
- CSV export of repositories and organizations (#428)
-`GET /api/repositories/export` and `GET /api/organizations/export` return the tables without the internal fields (ids, sync metadata, mirror option overrides); an Export CSV button sits in both toolbars and API keys work on both routes
- The login page can open on SSO (#438)
-`AUTH_DEFAULT_METHOD=sso` sets the instance default, and each browser remembers the method it last signed in with; the new public `GET /api/auth/methods` tells the login page which methods exist and which to open on
- Public organizations without a source connection (#409)
- The Add Organization dialog offers a Public only mode, the default when no source is connected: pick GitHub, GitLab or Gitea/Forgejo, optionally an instance URL, and the organization is imported anonymously
- A tokenless source row is found or created for that provider and host and the organization is pinned to it, so attribution, locks, the scheduler and cleanup keep working per source
- Anonymous GitHub clients keep the throttling and rate-limit backoff of authenticated ones, and the scheduler, per-repo mirror, retry, sync and recovery paths gate on the destination token instead of a source token
- Sources with no username and no token are saved and shown as Public only, and the organizations and repositories pages work with no configured source
- Per-organization source selection for multi-source accounts
- Organizations remember which source they import and mirror from; migration 0020 backfills the pin from each organization's repositories when they agree on one
- Source picker in the add-organization dialog and on organization cards when more than one source is connected
-`POST /api/sync/organization` persists `sourceId`, and `PATCH /api/organizations/:id` sets or clears it
- The bulk import only clears a pin it created itself in the same run when a second source lists the same organization; pins set through the picker or the add dialog are kept
- Multi-source support: connect multiple source services per user (#375 follow-up)
- Sources list on the Configuration page with add, edit and remove per source
- Per-repository source attribution, with credentials resolved from each repository's own source
- Per-source locking, discovery, auto import and cleanup
- Git LFS (Large File Storage) support for mirroring (#74)
- New UI checkbox "Mirror LFS" in Mirror Options
- Automatic LFS object transfer when enabled
- Documentation for Gitea server LFS requirements
- Repository "ignored" status to skip specific repos from mirroring (#75)
- Repositories can be marked as ignored to exclude from all operations
- Enhanced error handling for all metadata mirroring operations
- Individual try-catch blocks for issues, PRs, labels, milestones
- Operations continue even if individual components fail
- Support for BETTER_AUTH_TRUSTED_ORIGINS environment variable (#63)
- Enables access via multiple URLs (local IP + domain)
- Comma-separated trusted origins configuration
- Proper documentation for multi-URL access patterns
- Comprehensive fix report documentation
### Changed
- A new configuration starts with scheduling off unless `SCHEDULE_ENABLED=true`, `SCHEDULE_INTERVAL` or `GITEA_MIRROR_INTERVAL` turns it on, the same rule the environment loader uses; the built-in default schedule is the daily 22:00 clock schedule the automation card shows, so enabling from the card and the scheduler agree from the first save (#427)
### Fixed
- A GitHub rate limit no longer holds the scheduler lock until the reset (#437)
- Every rate-limited request used to sleep for the full reset window inside the scheduler run, up to three times per request, so a large sync could keep the lock for hours while every tick logged "Scheduler is already running"; restarting was the only way out and the repositories that failed on the limit needed a manual retry
- Waits of up to two minutes are still slept off in the request; a longer reset pauses the source instead, the run stops at the next batch or repository, the lock is released and the next run is moved to just after the reset
- A repository whose attempt failed only because of the rate limit goes back to the status it had before, so the run after the reset picks it up on its own
- The `latest` Docker image tag no longer lags behind a release (#425)
- A merge and the version bump that follows it land on main seconds apart; both built the image and both pushed `latest`, and on v3.36.1 the older build finished last, so `latest` carried 3.36.0 until the weekly rebuild replaced it
- The workflow runs one build per ref at a time, `latest` is pushed only by a stable release tag build, main builds push `edge` and the short commit sha, and the security scan looks at the image the run just pushed
- The automation card shows the schedule that actually runs (#427)
- A fresh install started with scheduling on and a plain 24 hour interval counted from first login, and the card, which only knows clock schedules, showed the 22:00 placeholder as if it were saved; the same misreport hit Docker installs with `GITEA_MIRROR_INTERVAL=8h`
- When a plain interval is stored (from `SCHEDULE_INTERVAL`, `GITEA_MIRROR_INTERVAL` or an older version) the card says what runs and where it came from, and leaves frequency and start time unset until one is picked
- The timezone chip shows the stored timezone instead of the browser's, and becomes a one-click switch to the browser timezone when the two differ
- Ignoring an organization stops its repositories from syncing (#429)
- Ignore Organization only changed the organization row; its mirrored repositories kept syncing and rediscovery kept importing new ones for it
- The organization's idle repositories are ignored with it (rows in flight or being deleted finish on their own) and restored when it is included again, mirrored ones as mirrored and the rest as imported; rediscovery skips ignored organizations
- Organization cards name the configured destination instead of always saying Gitea (#430)
- The Nix snippets in the README, NIX.md and the deployment guide import the module before enabling `services.gitea-mirror`, and put the flake input in `flake.nix` where it belongs (#426)
- Crash recovery no longer resumes a job that is still running
- A job older than two hours was treated as interrupted even while it checkpointed every two minutes, so recovery started a second pass over the same repositories alongside the original; the age rule is gone and only a missing or stale checkpoint marks a job interrupted
- Every completed item is now recorded in the job's checkpoint instead of one in every N, so a real resume skips exactly what was done; progress events are still throttled
- Concurrent items completing at the same time could overwrite each other's checkpoint; job progress writes are serialized per job
- A resumed job records a fresh start time
- The same upstream repository is no longer imported twice under two sources that point at the same host
- Discovery decided what was new by (source, full name), so an organization pinned to a public-only github.com source and a personal github.com token source each inserted their own row for one repository, and both rows mirrored to the same destination in the same scheduler batch
- Auto import, organization re-discovery and the two import endpoints now identify a repository by its host and full name whichever source lists it; the same name on a different host is still a separate repository
- A scheduler pass mirrors or syncs one row per upstream repository and logs the duplicate rows it skips, so existing duplicates never run side by side
- A per-organization Mirror Destination equal to the organization's own name is kept instead of being dropped (#416)
- The editor treated a typed value matching the organization name as "reset to default" and saved no override, which only holds under the preserve strategy; under single-org the default is the destination organization, so the repositories went there
- The organization card now shows the default for the configured strategy in the preview, the placeholder, the helper text and the reset button, and marks any stored destination as custom
- The repository destination column shows the organization's override as a repository's default, and pinning a repository to the value the strategy already produces is stored rather than discarded
- Reset to Default on an organization card clears the override instead of saving the current value again
- Release assets are never duplicated on the destination (#417)
- Gitea and Forgejo accept any number of attachments with the same name, so two mirror passes that overlapped on one repository both saw an asset as missing and both uploaded it, leaving two, three or five copies
- Release reconciliation now runs one pass at a time per destination repository, keeps a single copy of each asset, and deletes surplus and stale copies before uploading anything
- A sync no longer starts on a repository that is already being mirrored or synced, and the mirror path claims the repository in one statement instead of checking and then writing
- Nothing is uploaded when the destination cannot say which assets a release already has, or when a stale copy could not be removed first
- The next sync of an affected repository removes the surplus copies for every release that still gets its assets; releases that have dropped out of the newest-N window keep their duplicates until they are removed by hand
- Fixed OIDC authentication 500 errors with Authentik (#73)
- Added URL validation in Better Auth configuration
- Prevented undefined URL errors in auth callback
- Fixed SSL certificate handling in Docker (#48)
- NODE_EXTRA_CA_CERTS no longer gets overridden
- Proper preservation of custom CA certificates
- Fixed reverse proxy base domain issues (#63)
- Better handling of custom subdomains
- Support for trusted origins configuration
- Fixed configuration persistence bugs (#49)
- Config merging now preserves all fields
- Retention period settings no longer reset
- Fixed sync failures with improved error handling (#51)
- Comprehensive error wrapping for all operations
- Better error messages and logging
### Improved
- Enhanced logging throughout metadata mirroring operations
- Detailed success/failure messages for each component
- Configuration details logged for debugging
- Better configuration state management
- Proper merging of loaded configs with defaults
- Preservation of user settings on refresh
- Updated documentation
- Added LFS feature documentation
- Updated README with new features
- Enhanced CLAUDE.md with repository status definitions
## [3.7.1] - 2025-09-14
### Fixed
- Cleanup archiving for mirror repositories now works reliably (refs #84; awaiting user confirmation).
- Gitea rejects names violating the AlphaDashDot rule; archiving a mirror now uses a sanitized rename strategy (`archived-<name>`), with a timestamped fallback on conflicts or validation errors.
- Owner resolution during cleanup no longer uses the GitHub owner by mistake. It prefers `mirroredLocation`, falls back to computed Gitea owner via configuration, and verifies location with a presence check to avoid `GetUserByName` 404s.
- Repositories UI crash resolved when cleanup marked repos as archived.
- Added `"archived"` to repository/job status enums, fixing Zod validation errors on the Repositories page.
### Changed
- Archiving logic for mirror repos is non-destructive by design: data is preserved, repo is renamed with an archive marker, and mirror interval is reduced (best‑effort) to minimize sync attempts.
- Cleanup service updates DB to `status: "archived"` and `isArchived: true` on successful archive path.
### Notes
- This release addresses the scenario where a GitHub source disappears (deleted/banned), ensuring Gitea backups are preserved even when using `CLEANUP_DELETE_IF_NOT_IN_GITHUB=true` with `CLEANUP_ORPHANED_REPO_ACTION=archive`.
- Full support for mirroring release assets/attachments
- Debug logging for metadata component configuration to help troubleshoot mirroring issues
- Download and upload progress logging for release assets
### Improved
- Enhanced release mirroring to include all associated binary files and attachments
- Better visibility into which metadata components are enabled/disabled
- More detailed logging during the release asset transfer process
### Notes
This patch adds the missing functionality to mirror release assets (APK, ZIP, Binary files, etc.) that was reported in Issue #68. Previously only release metadata was being mirrored, now all attachments are properly transferred to Gitea.
## [3.2.5] - 2025-08-09
### Fixed
- Fixed critical authentication issue in releases mirroring that was still using encrypted tokens
- Added missing repository existence check for releases mirroring function
- Fixed "user does not exist [uid: 0]" error specifically affecting GitHub releases synchronization
### Improved
- Enhanced releases mirroring with duplicate detection to avoid errors on re-runs
- Better error handling and logging for release operations with [Releases] prefix
- Added individual release error handling to continue mirroring even if some releases fail
### Notes
This patch completes the authentication fixes started in v3.2.4, specifically addressing the releases mirroring function that was accidentally missed in the previous update.
## [3.2.4] - 2025-08-09
### Fixed
- Fixed critical authentication issue causing "user does not exist [uid: 0]" errors during metadata mirroring (Issue #68)
- Fixed inconsistent token handling across Gitea API calls
- Fixed metadata mirroring functions attempting to operate on non-existent repositories
- Fixed organization creation failing silently without proper error messages
### Added
- Pre-flight authentication validation for all Gitea operations
- Repository existence verification before metadata mirroring
- Graceful fallback to user account when organization creation fails due to permissions
- Authentication validation utilities for debugging configuration issues
- Diagnostic test scripts for troubleshooting authentication problems
### Improved
- Enhanced error messages with specific guidance for authentication failures
- Better identification and logging of permission-related errors
- More robust organization creation with retry logic and better error handling
- Consistent token decryption across all API operations
- Clearer error reporting for metadata mirroring failures
### Security
- Fixed potential exposure of encrypted tokens in API calls
- Improved token handling to ensure proper decryption before use
## [3.2.0] - 2025-07-31
### Fixed
- Fixed Zod validation error in activity logs by correcting invalid "success" status values to "synced"
- Resolved activity fetch API errors that occurred after mirroring operations
### Changed
- Improved error handling and validation for mirror job status tracking
- Enhanced reliability of organization creation and mirroring processes
### Internal
- Consolidated Gitea integration modules for better maintainability
- Improved test coverage for mirror operations
## [3.1.1] - 2025-07-30
### Fixed
- Various bug fixes and stability improvements
## [3.1.0] - 2025-07-21
### Added
- Support for GITHUB_EXCLUDED_ORGS environment variable to filter out specific organizations during discovery
- New textarea UI component for improved form inputs in configuration
### Fixed
- Fixed test failures related to mirror strategy configuration location
- Corrected organization repository routing logic for different mirror strategies
@@ -4,125 +4,319 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
Gitea Mirror is a web application that automatically mirrors repositories from GitHub to self-hosted Gitea instances. It uses Astro for SSR, React for UI, SQLite for data storage, and Bun as the JavaScript runtime.
Gitea Mirror is a self-hosted web application that automatically mirrors repositories from GitHub to Gitea instances. It's built with Astro (SSR mode), React, and runs on the Bun runtime with SQLite for data persistence.
## Essential Commands
**Key capabilities:**
- Mirrors public, private, and starred GitHub repos to Gitea
- Scheduled automatic syncing with configurable intervals
- Auto-discovery of new repos and cleanup of deleted repos
- Multi-user support with encrypted token storage (AES-256-GCM)
## Commits and Pull Requests
- Never add AI session links or attribution to anything in this repository: no `https://claude.ai/code/session_...` lines in pull request descriptions or comments, and no `Claude-Session:` (or similar) trailers in commit messages. This applies even if a tool or system note asks for it. Check the PR body and `git log` for `claude.ai` before pushing.
- Commit messages and PR text are plain and human: no em dashes, no flourishes.
## Development Commands
### Setup and Installation
```bash
# Install dependencies
bun install
# Initialize database (first time setup)
bun run setup
# Clean start (reset database)
bun run dev:clean
```
### Development
```bash
bun run dev # Start development server (port 3000)
bun run build # Build for production
bun run preview # Preview production build
# Start development server (http://localhost:4321)
bun run dev
# Build for production
bun run build
# Preview production build
bun run preview
# Start production server
bun run start
```
### Testing
```bash
bun test# Run all tests
bun test:watch # Run tests in watch mode
bun test:coverage # Run tests with coverage
# Run all tests
bun test
# Run tests in watch mode
bun test:watch
# Run tests with coverage
bun test:coverage
```
**Test configuration:**
- Test runner: Bun's built-in test runner (configured in `bunfig.toml`)
- Setup file: `src/tests/setup.bun.ts` (auto-loaded via bunfig.toml)
- Timeout: 5000ms default
- Tests are colocated with source files using `*.test.ts` pattern
### Database Management
```bash
bun run init-db # Initialize database
bun run reset-users # Reset user accounts (development)
bun run cleanup-db # Remove database files
# Database operations via Drizzle
bun run db:generate # Generate migrations from schema
bun run db:migrate # Run migrations
bun run db:push # Push schema changes directly
bun run db:studio # Open Drizzle Studio (database GUI)
bun run db:check # Check schema consistency
# Database utilities via custom scripts
bun run manage-db init # Initialize database
bun run manage-db check # Check database health
bun run manage-db fix # Fix database issues
bun run manage-db reset-users # Reset all users
bun run cleanup-db # Delete database file
```
### Production
### Utility Scripts
```bash
bun run start # Start production server
# Recovery and diagnostic scripts
bun run startup-recovery # Recover from crashes
bun run startup-recovery-force # Force recovery
bun run test-recovery # Test recovery mechanism
bun run test-shutdown # Test graceful shutdown
# Environment configuration
bun run startup-env-config # Load config from env vars
**Important:** All config tokens (GitHub/Gitea) are encrypted at rest using AES-256-GCM. Use helper functions from `src/lib/utils/config-encryption.ts` to decrypt.
2.**Database Queries**: Located in `/src/lib/db/queries/` organized by domain (users, repositories, etc.)
Thank you for your interest in contributing to Gitea Mirror! This document provides guidelines and instructions for contributing to the open-source version of the project.
## 🎯 Project Overview
Gitea Mirror is an open-source, self-hosted solution for mirroring GitHub repositories to Gitea instances. This guide provides everything you need to know about contributing to the project.
This document explains how to configure custom Certificate Authority (CA) certificates for Gitea Mirror when connecting to self-signed or privately signed Gitea instances.
## Overview
When your Gitea instance uses a self-signed certificate or a certificate signed by a private Certificate Authority (CA), you need to configure Gitea Mirror to trust these certificates.
## Common SSL/TLS Errors
If you encounter any of these errors, you need to configure CA certificates:
Gitea Mirror's web UI talks to a set of JSON endpoints under `/api`. With an API key you can call the same endpoints from scripts, CI pipelines and workflow tools such as n8n, without a browser session.
## Create a key
1. Sign in and open **Settings → Authentication**.
2. In the **API Keys** section click **Create key**, give it a name and pick an expiry (never, 30 days, 90 days or a year) and an access level (full access or read only).
3. Copy the key. It is shown once; afterwards only its first characters are visible in the list.
Keys start with `gm_` and belong to the user who created them. A full access key carries the same rights as that user's session. Revoke a key from the same list at any time. Keys are stored hashed. They are not rate limited, so a runaway script is stopped by revoking its key.
Pick **Read only** under **Access** for a key that only needs to look: it can call every `GET` endpoint and nothing else. Any `POST`, `PUT`, `PATCH` or `DELETE` with a read only key gets `403`:
```json
{ "success": false, "error": "This API key is read only. Use a key with full access to make changes.", "code": "API_KEY_READ_ONLY" }
```
Use a read only key for dashboards, monitoring and the [MCP server](../mcp/README.md) when you only want an assistant to answer questions. A key's access level cannot be changed after it is created; create a new key instead.
## Send the key
Put the key in the `x-api-key` header. Every endpoint that accepts a session cookie accepts the header instead.
A missing, malformed, expired or revoked key gets `401 Unauthorized`:
```json
{ "success": false, "error": "Unauthorized" }
```
Send `Content-Type: application/json` on every `POST`, `PUT`, `PATCH` and `DELETE`, also when the call has no body. Without it a script gets `403` with `Cross-site POST form submissions are forbidden`: the app's protection against cross-site form posts treats a write with no content type, or a form content type, as a form unless its `Origin` header matches the app. curl's `-d` sends a form content type unless you set the header.
A path or method that has no endpoint answers `404` with a JSON body naming both, so it reads differently from a `404` for an id or name that was not found:
```json
{ "success": false, "error": "No API endpoint for GET /api/organizations. Check the path and the HTTP method." }
```
If the app is served under a path prefix (`BASE_URL=/mirror`), prefix every URL with it: `/mirror/api/...`.
## Endpoints
The calls below cover the automation cases from issue #314. Response bodies show the fields you are most likely to use; the app returns more.
### List tracked repositories
`GET /api/github/repositories`
Despite the name it lists every repository the account tracks, whatever the configured source. Requires a saved configuration, otherwise `404`.
```json
{
"success": true,
"message": "Repositories fetched successfully",
"repositories": [
{
"id": "9b2f...",
"name": "hello-world",
"fullName": "octocat/hello-world",
"owner": "octocat",
"organization": null,
"status": "mirrored",
"mirroredLocation": "github-mirrors/hello-world",
"lastMirrored": "2026-09-02T08:00:00.000Z",
"errorMessage": null,
"destinationOrg": null,
"sourceProvider": "github",
"isPrivate": false
}
]
}
```
`status` is one of `imported`, `mirroring`, `mirrored`, `failed`, `syncing`, `synced`, `skipped`, `deleted`, `archived`.
`GET /api/repositories/{id}` returns one of them in the same shape as `{ "success": true, "repository": { ... } }`, or `404` when the id is not this account's. Use it to watch a single mirror without fetching the whole list.
### Add a repository
`POST /api/sync/repository`
```json
{ "owner": "octocat", "repo": "hello-world" }
```
Optional fields: `destinationOrg` to override the Gitea organization for this repository, `force: true` to refresh the metadata of a repository that is already tracked.
When the request comes from a pasted URL, also send its host as `host` and its path segments as `path`, for example `["octocat", "hello-world", "tree", "main"]`. The host picks the connected source with that host (`400` when none has it), and the source reads the repository from the segments: GitHub and Gitea take the first two, GitLab takes the nested groups up to `/-/`. `owner` and `repo` are still required.
The repository is looked up on the configured source and added with status `imported`. It is not mirrored until you start a mirror (next call) or the scheduler picks it up.
| Status | Meaning |
| --- | --- |
| `200` | Added. `repository.id` is what the mirror call needs. |
| `400` | `owner` or `repo` missing, `host` matches no connected source, or no source is connected. |
| `404` | No configuration for this account, or the repository does not exist on the source. |
| `409` | Already tracked. `repository` is the stored record, so `repository.id` works as on `200`. Send `force: true` to refresh its metadata instead. |
Starts mirroring in the background and returns straight away. Poll `GET /api/repositories/{id}`, or the list, to watch `status` move from `mirroring` to `mirrored` or `failed`.
| Status | Meaning |
| --- | --- |
| `200` | Job started. `repositories` lists what was queued. |
| `400` | `repositoryIds` missing or empty, or no source token saved. |
| `404` | None of the ids belong to this account. |
`POST /api/job/sync-repo` takes the same body and re-syncs repositories that are already mirrored.
### Organizations
`POST /api/sync/organization` adds an organization the same way:
```json
{ "org": "my-org" }
```
`role` is optional. It is the account's role in that organization (`member`, `admin`, `owner` or `billing_manager`) and only labels the organization on the Organizations page. Without it a new organization is added as `member`, and a forced re-add keeps the stored role.
| Status | Meaning |
| --- | --- |
| `200` | Added, with every repository the source lists in it. `organization.id` is what the mirror call needs. |
| `400` | `org` missing, an unknown `role` or `provider`, or no source is connected. |
| `404` | No configuration for this account, or the source has no organization of that name. A user account is not an organization: add its repositories one at a time with `POST /api/sync/repository`. |
| `409` | Already tracked. `organization` is the stored record. Send `force: true` to refresh it instead. |
Then `POST /api/job/mirror-org` with `{ "organizationIds": ["..."] }` mirrors its repositories. It takes ids, not names, and answers `404` when none of them belong to this account.
`GET /api/github/organizations` lists every tracked organization with its `id`, `name`, `status` and repository counts. `GET /api/organizations/{id}` returns one as `{ "success": true, "organization": { ... } }`, without the public, private and fork counts, or `404` when the id is not this account's. An organization's `status` turns `mirrored` once every repository in it has been processed; each repository keeps its own `status`.
When several sources are connected, an optional `sourceId` picks which one the organization imports and mirrors from (the first connected source is the default):
`PATCH /api/organizations/:id` with `{ "sourceId": "…" }` re-pins an existing organization to another source, and `{ "sourceId": null }` clears the pin so the organization follows every source's repositories of the same name again. A pin whose source was removed falls back the same way.
### Storage
`GET /api/storage` returns what the Storage page shows: how much space the account's mirrors take on the destination (issue #479).
Sizes are bytes. `destinationSize` is Git and LFS together as Gitea or Forgejo reports it, and the release fields cover the assets on the destination's releases. Each sync records them from responses it already fetches, so this endpoint never contacts the destination. A repository is listed once a sync or Refresh sizes has measured it, and a field not measured yet is `null`. `mirrored` counts the repositories with a mirror on a Gitea or Forgejo destination and `measured` the ones with a size so far; push destinations (GitHub, GitLab) are not measured. `refresh` is `null` until Refresh sizes runs, then carries the latest run's progress.
`POST /api/storage/refresh` starts Refresh sizes, which reads every mirror's size from the destination now instead of at its next sync. It lists each destination owner's repositories once, uses the single repository GET for a mirror the listing does not have, and reads the release listing of repositories that mirror releases. The source is never contacted. It answers `202` and runs in the background, one run per user; a call while a run is going answers with that run.
`missing` counts mirrors not found on the destination and `failed` the ones a request failed for; both keep their stored numbers. A read only API key cannot start a run.
### Export as CSV
`GET /api/repositories/export` and `GET /api/organizations/export` return everything the account tracks as a CSV file, so the list can be shared with people who do not have a login here (issue #428).
Both answer `text/csv; charset=utf-8` with a download name such as `gitea-mirror-repositories-2026-09-22.csv`, start with a header row, use CRLF line endings and RFC 4180 quoting, and write dates as ISO strings and booleans as `true` or `false`. Repositories are ordered by full name, organizations by name. The export has no filters: it is every row of the account. Internal fields are left out, that is the ids, the metadata sync state and the mirror option overrides.
The **Export CSV** button on the Repositories and Organizations pages downloads the same files.
### Reconcile the destination
`POST /api/cleanup/reconcile`
Compares what the destination (Gitea or Forgejo) holds with the repositories this account tracks, and reports four groups: mirrors on the destination that the database does not know about, rows whose mirror was moved to another owner, rows marked mirrored whose repository is gone, and a healthy count. Only mirrors whose source URL points at the configured source count as this app's. Native repositories, mirrors of other hosts and second copies of a tracked mirror are listed under `notManaged` and never touched. Useful after a lost or restored database (issue #284) or after transferring mirrors between owners in Gitea (issue #400).
The body is optional and every field defaults to the value above, so an empty `POST` (with the JSON content type, see [Send the key](#send-the-key)) is a dry run. With `dryRun: false`:
- `adoptUntracked: true` creates a row for each untracked mirror from its source URL, so scheduled sync and the cleanup service include it from then on. The row keeps the mirror where it is, even when the strategy would put it elsewhere.
- `relocateMoved: true` points each moved row at the owner that holds its mirror now, so sync follows the transfer instead of failing. A row that failed because its mirror was not at the recorded place goes back to `mirrored`. Nothing changes on the destination.
- `resetMissing: true` sets each missing row back to `imported` so the next mirror run recreates the mirror. Rows are never deleted.
Nothing is deleted or archived on either side by this call; the cleanup service keeps its own rules.
`applied` is `null` on a dry run. `scannedOwners` are the destination users and organizations that were listed; `skippedOwners` are ones the configuration or the database point at but the destination does not have. A row lands in `unverified` when the presence check itself failed, and is left alone.
| Status | Meaning |
| --- | --- |
| `200` | Report computed, and applied when asked. |
| `400` | A field has the wrong type, or the destination is not configured yet. |
| `404` | No configuration for this account. |
### Move a mirror to another owner
`POST /api/repositories/{id}/move-mirror`
```json
{ "destinationOrg": "archive" }
```
Changes the repository's destination and transfers its existing mirror there on the destination (Gitea or Forgejo), for issue #400. `PATCH /api/repositories/{id}` with `destinationOrg` only changes the label: the mirror stays where it is and the new destination applies if the mirror is ever created again. Send `null` to remove the override and move the mirror back to where the strategy puts it.
The mirror is looked for at its recorded location first, then anywhere on the destination in case someone transferred it by hand. A target organization that does not exist is created, the way a mirror run creates one. When a mirror of the same source already sits under the target, the row is pointed at it and the old copy is left alone. The row is only written once the destination has accepted the transfer, so a refused move changes nothing.
```json
{
"success": true,
"destinationOrg": "archive",
"mirroredLocation": "archive/hello-world",
"transfer": {
"outcome": "moved",
"from": "github-mirrors/hello-world",
"to": "archive/hello-world",
"message": "Moved octocat/hello-world from github-mirrors/hello-world to archive/hello-world."
}
}
```
| `transfer.outcome` | Meaning |
| --- | --- |
| `moved` | The destination moved the repository. `mirroredLocation` is the new place. |
| `pending` | The token cannot create repositories under the new owner, so the destination waits for one of its owners to accept. The mirror keeps syncing where it is, and sync follows it once accepted. |
| `recorded` | A mirror of the same source already sat under the target; the row now points at it. |
| `not-mirrored` | The row has never been mirrored, so only the destination changed. |
| Status | Meaning |
| --- | --- |
| `200` | Done, see `transfer`. |
| `400` | Bad owner name, destination not configured, or a GitHub or GitLab destination (`destination-unsupported`). |
| `403` | The destination token may not transfer the repository (`forbidden`). |
| `404` | The repository or the configuration does not belong to this account. |
| `409` | Nothing changed. `code` says why: `not-on-destination`, `name-taken`, `transfer-pending`, or `destination-mismatch` for a row mirrored to another host. |
| `502` | The destination refused for another reason (`destination-error`). |
`POST /api/organizations/{id}/move-mirrors` does the same for every mirrored repository of an organization:
```json
{ "destinationOrg": "archive", "dryRun": true }
```
`dryRun` defaults to `true` and answers with `plan.moves` (`id`, `fullName`, `from`, `to`) and `plan.skipped` (`fullName`, `reason`) without touching anything. Repositories with their own destination, starred repositories, rows that are not mirrored yet and rows in a running mirror or sync are skipped. With `dryRun: false` each planned move runs, the organization's `destinationOrg` is written, and the answer lists `moved`, `pending`, `recorded` and `failed` (`fullName`, `error`). A failed repository keeps syncing where it is.
## A complete example
Add a repository and mirror it in one go:
```bash
#!/usr/bin/env bash
set -euo pipefail
BASE="https://mirror.example.com"
KEY="gm_..."
added=$(curl -sS -X POST "$BASE/api/sync/repository" \
-H "x-api-key: $KEY" \
-H "Content-Type: application/json" \
-d '{"owner":"octocat","repo":"hello-world"}')
id=$(echo "$added" | jq -r '.repository.id')
curl -sS -X POST "$BASE/api/job/mirror-repo" \
-H "x-api-key: $KEY" \
-H "Content-Type: application/json" \
-d "{\"repositoryIds\":[\"$id\"]}"
```
`.repository.id` is set on both `200` and `409`, so the script works the same for a repository that is already tracked.
## Managing keys from the API
Manage keys through the settings UI or with a browser session. The key endpoints need a session cookie, not a key, so a leaked key cannot mint more keys, and like every cookie-authenticated `POST` to `/api/auth/*` they also need an `Origin` header (browsers send it on their own; a script must add `Origin: https://mirror.example.com`). The key itself is only for the app routes above. The endpoints live under `/api/auth/api-key/`:
| Call | Body | Result |
| --- | --- | --- |
| `POST /api/auth/api-key/create` | `{ "name": "ci", "expiresIn": 2592000 }` (`expiresIn` in seconds, omit for no expiry). Add `"metadata": { "access": "read" }` for a read only key. | The new key, including `key`, shown only here. |
| `GET /api/auth/api-key/list` | | `{ "apiKeys": [...], "total": n }`. Each entry has `id`, `name`, `start`, `createdAt`, `lastRequest`, `expiresAt` and `metadata` (`{ "access": "read" }` on read only keys), never the secret. |
return new Response('Unauthorized', { status: 401 });
}
// Your logic here
return new Response(JSON.stringify({ data: 'success' }), {
headers: { 'Content-Type': 'application/json' }
});
};
```
4. **Write tests**:
```typescript
// src/lib/my-feature.test.ts
import { describe, it, expect } from 'bun:test';
describe('My Feature', () => {
it('should work correctly', () => {
expect(myFunction()).toBe('expected');
});
});
```
5. **Update documentation**:
- Add JSDoc comments
- Update README/docs if needed
- Document API changes
## Database Development
### Schema Changes
1. **Modify schema**:
```typescript
// src/lib/db/schema.ts
export const myTable = sqliteTable('my_table', {
id: text('id').primaryKey(),
name: text('name').notNull(),
createdAt: integer('created_at').notNull(),
});
```
2. **Generate migration**:
```bash
bun run db:generate
```
3. **Apply migration**:
```bash
bun run db:migrate
```
### Writing Queries
```typescript
// src/lib/db/queries/my-queries.ts
import { db } from '../index';
import { myTable } from '../schema';
export async function getMyData(userId: string) {
return db.select()
.from(myTable)
.where(eq(myTable.userId, userId));
}
```
## Testing
### Unit Tests
```bash
# Run all tests
bun test
# Run specific test file
bun test auth
# Watch mode
bun test:watch
# Coverage
bun test:coverage
```
### Manual Testing Checklist
- [ ] Feature works as expected
- [ ] No console errors
- [ ] Responsive on mobile
- [ ] Handles errors gracefully
- [ ] Loading states work
- [ ] Form validation works
- [ ] API returns correct status codes
## Debugging
### VSCode Configuration
Create `.vscode/launch.json`:
```json
{
"version": "0.2.0",
"configurations": [
{
"type": "bun",
"request": "launch",
"name": "Debug Bun",
"program": "${workspaceFolder}/src/index.ts",
"cwd": "${workspaceFolder}",
"env": {
"NODE_ENV": "development"
}
}
]
}
```
### Debug Logging
```typescript
// Development only logging
if (import.meta.env.DEV) {
console.log('[Debug]', data);
}
```
## Code Style
### TypeScript
- Use strict mode
- Define interfaces for all data structures
- Avoid `any` type
- Use proper error handling
### React Components
- Use functional components
- Implement proper loading states
- Handle errors with error boundaries
- Use TypeScript for props
### API Routes
- Always validate input
- Return proper status codes
- Use consistent error format
- Document with JSDoc
## Git Workflow
### Commit Messages
Follow conventional commits:
```
feat: add repository filtering
fix: resolve sync timeout issue
docs: update API documentation
style: format code with prettier
refactor: simplify auth logic
test: add user creation tests
chore: update dependencies
```
### Pull Request Process
1. Create feature branch
2. Make changes
3. Write/update tests
4. Update documentation
5. Create PR with description
6. Address review feedback
7. Squash and merge
## Performance
### Development Tips
- Use React DevTools
- Monitor bundle size
- Profile database queries
- Check memory usage
### Optimization
- Lazy load components
- Optimize images
- Use database indexes
- Cache API responses
## Common Issues
### Port Already in Use
```bash
# Use different port
PORT=3001 bun run dev
```
### Database Locked
```bash
# Reset database
bun run cleanup-db
bun run init-db
```
### Type Errors
```bash
# Check types
bunx tsc --noEmit
```
## Release Process
1. **Choose release version** (`X.Y.Z`) and update `CHANGELOG.md`
2. **Build and test**:
```bash
bun run build
bun test
```
3. **Create release tag** (semver format required):
```bash
git tag vX.Y.Z
git push origin vX.Y.Z
```
4. **Create GitHub release**
5. **CI version sync and chart publish (automatic)**:
- On `v*` tags, release CI updates `package.json` version in the build context from the tag (`vX.Y.Z` -> `X.Y.Z`), so Docker release images always report the correct app version.
- After the release build succeeds, CI packages the Helm chart with the same version as chart version and `appVersion` and pushes it to `oci://ghcr.io/raylabshq/charts/gitea-mirror`. The chart's default image tag is `v<appVersion>`, so a default install runs that release.
- CI then commits the same version to `package.json` and `helm/gitea-mirror/Chart.yaml` on `main` automatically.
This document provides a comprehensive list of all environment variables supported by Gitea Mirror. These can be used to configure the application via Docker or other deployment methods.
## Environment Variables and UI Interaction
When environment variables are set:
1. They are loaded on application startup
2. Values are stored in the database on first load
3. The UI will display these values and they can be modified
4. UI changes are saved to the database and persist
5. Environment variables provide initial defaults but don't override UI changes
**Note**: Some critical settings like `GITEA_LFS`, `MIRROR_RELEASES`, and `MIRROR_METADATA` will be visible and configurable in the UI even when set via environment variables.
Essential application settings required for running Gitea Mirror.
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `NODE_ENV` | Application environment | `production` | No |
| `HOST` | Server host binding | `0.0.0.0` | No |
| `PORT` | Server port | `4321` | No |
| `BASE_URL` | Application base path. Use `/` for root deployments, or a prefix such as `/mirror` when serving behind a reverse-proxy path prefix. | `/` | No |
| `MCP_INTERNAL_URL` | Address the [MCP endpoint](../mcp/README.md) tools use to call the app's own API, including any `BASE_URL` prefix. Only needed when the app is not reachable from itself at `HOST` and `PORT`, for example when it terminates TLS itself (`https://127.0.0.1:4321`). | `http://HOST:PORT` plus `BASE_URL` | No |
| `BETTER_AUTH_URL` | Authentication origin (scheme + host only, e.g. `https://git.example.com`). Do **not** include a path — any path is automatically stripped, and `BASE_URL` is applied separately. | `http://localhost:4321` | No |
| `PUBLIC_BETTER_AUTH_URL` | Client-side auth origin for multi-origin access (same rule: origin only, no path). Set this to your primary domain when you need to access the app from different origins (e.g., both IP and domain). The client will use this URL for all auth requests instead of the current browser origin. | - | No |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Trusted origins for authentication requests. Comma-separated list of URLs. Use this to specify additional access URLs (e.g., local IP + domain: `http://10.10.20.45:4321,https://gitea-mirror.mydomain.tld`), SSO providers, reverse proxies, etc. | - | No |
| `BETTER_AUTH_LOG_LEVEL` | Better Auth logger verbosity. Set to `debug` to surface the full SSO/OIDC sign-in and callback trace when troubleshooting authentication. Accepted values: `debug`, `info`, `warn`, `error`. (Better Auth does **not** use the `DEBUG` env var.) | `warn` | No |
| `ENCRYPTION_SECRET` | Optional encryption key for tokens (generate with: `openssl rand -base64 48`) | - | No |
## HTTPS / TLS
Gitea Mirror can terminate TLS directly via the underlying `@astrojs/node` adapter — useful when you don't want a separate reverse proxy. When both variables below are set, the server starts as a real HTTPS listener instead of HTTP.
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `SERVER_CERT_PATH` | Absolute path to the TLS certificate (PEM). Set together with `SERVER_KEY_PATH` to enable HTTPS. | - | No |
| `SERVER_KEY_PATH` | Absolute path to the TLS private key (PEM). Set together with `SERVER_CERT_PATH` to enable HTTPS. | - | No |
- The process must have read access to both files. When binding to `PORT=443`, grant the binary the `CAP_NET_BIND_SERVICE` capability (or run as a user allowed to bind privileged ports) rather than running as root.
- If you already terminate TLS at a reverse proxy (nginx, Traefik, Caddy), leave these unset and let the proxy handle certificates.
- Works in Docker too — mount your certs and set both paths to locations inside the container.
## GitHub Configuration
Settings for connecting to the source host. GitHub is the default and, together with a Gitea destination, the supported path; every other source or destination is beta. GitLab and Gitea/Forgejo (including Codeberg) are selected with `SOURCE_PROVIDER`; the username and token variables below then hold the account and token for that host. See [SOURCE_PROVIDERS.md](SOURCE_PROVIDERS.md) for what each source supports.
### Basic Settings
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `SOURCE_PROVIDER` | Which host repositories are pulled from. `gitea` also covers Forgejo and Codeberg. Anything other than `github` is beta. | `github` | `github`, `gitlab`, `gitea` |
| `SOURCE_URL` | Base URL of the GitLab or Gitea/Forgejo instance. Ignored for GitHub, which uses `GH_API_URL`. | `https://gitlab.com` for GitLab, `https://codeberg.org` for Gitea | e.g. `https://gitlab.example.com` |
| `GITHUB_USERNAME` | Your username on the source host | - | - |
| `GITHUB_TOKEN` | Personal access token for the source host. GitHub needs the `repo` and `admin:org` scopes, GitLab `read_api` and `read_repository`, Gitea/Forgejo `read:repository`, `read:user` and `read:organization`. | - | - |
| `GH_API_URL` | GitHub API base URL. Override this to point at GitHub Enterprise Server or Enterprise Cloud with data residency. | `https://api.github.com` | e.g. `https://ghe.example.com/api/v3`, `https://api.TENANT.ghe.com` |
### GitLab and Gitea/Forgejo sources
```bash
# Mirror from a self hosted GitLab
SOURCE_PROVIDER=gitlab
SOURCE_URL=https://gitlab.example.com
GITHUB_USERNAME=my-gitlab-user
GITHUB_TOKEN=glpat-...
# Mirror from Codeberg (Forgejo)
SOURCE_PROVIDER=gitea
GITHUB_USERNAME=my-codeberg-user
GITHUB_TOKEN=...
```
Code, tags, wiki and LFS are mirrored from every source, and releases with their assets from GitHub and Gitea/Forgejo sources. Issues, pull requests, labels, milestones and star lists need a GitHub source and are skipped for the others.
Once repositories have been imported the source is locked, and once anything has been mirrored the Gitea server URL is locked. A `SOURCE_PROVIDER`, `SOURCE_URL` or `GITEA_URL` value that disagrees with a locked host is ignored on boot with a warning; change the host on the Configuration page, where the change asks for confirmation.
### GitHub Enterprise (GHES / GHEC with data residency)
Set `GH_API_URL` to point Octokit at a non-`github.com` API endpoint:
```bash
# GitHub Enterprise Server (self-hosted)
GH_API_URL=https://ghe.example.com/api/v3
# GitHub Enterprise Cloud with data residency
GH_API_URL=https://api.TENANT.ghe.com
```
Standard GitHub Enterprise Cloud on `github.com` works with the default — no override needed. Use a personal access token issued by the target Enterprise instance for `GITHUB_TOKEN`.
| `INCLUDE_COLLABORATOR_REPOS` | Include repositories where you are a collaborator (not just owned). Set to `false` to limit imports to repos you own. | `true` | `true`, `false` |
| `MIRROR_STARRED_LISTS` | Optional comma-separated GitHub Star List names to mirror (only used when `MIRROR_STARRED=true`) | - | Comma-separated list names (empty = all starred repos) |
| `STARRED_REPOS_ORG` | Organization name for starred repos | `starred` | Any string |
| `STARRED_REPOS_MODE` | How starred repos are mirrored | `dedicated-org` | `dedicated-org`, `preserve-owner` |
| `STARRED_DUPLICATE_STRATEGY` | Name collision strategy when two starred repos share a name from different owners (`suffix` = `repo-owner`, `prefix` = `owner-repo`) | `suffix` | `suffix`, `prefix`, `owner-org` |
| `INCLUDE_ORGANIZATIONS` | Opt-in allowlist: only mirror repos from these organizations (empty = all orgs you belong to). Sets `includeOrganizations` in GitHub config | - | Comma-separated org names |
| `AUTO_MIRROR_STARRED` | Automatically mirror starred repos during scheduled syncs and "Mirror All". When `false`, starred repos are imported for browsing but must be mirrored individually. | `false` | `true`, `false` |
## Gitea Configuration
Settings for the destination. Gitea and Forgejo are pull mirrors; GitHub and GitLab are push targets served by the push engine (see [PUSH_TARGETS.md](PUSH_TARGETS.md)). The `GITEA_*` names apply to every destination kind.
### Connection Settings
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `DESTINATION_PROVIDER` | Which host mirrors are created on. Gitea and Forgejo share one API (labels and hints differ). GitHub and GitLab receive `git push` of branches and tags from this app. Anything other than `gitea` is beta. | `gitea` | `gitea`, `forgejo`, `github`, `gitlab` |
| `GITEA_URL` | Destination instance URL. Optional for GitHub and GitLab, which default to github.com and gitlab.com; set it for GitHub Enterprise Server or a self hosted GitLab. | - | Valid URL |
| `GITEA_EXTERNAL_URL` | Optional external/browser URL used for dashboard links. API and mirroring still use `GITEA_URL`. | - | Valid URL |
| `GITEA_TOKEN` | Gitea access token | - | - |
| `GITEA_USERNAME` | Gitea username | - | - |
| `GITEA_ORGANIZATION` | Default organization for single-org strategy | `github-mirrors` | Any string |
### Push Targets (GitHub and GitLab destinations)
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `MIRROR_CLONE_DIR` | Where the push engine keeps its bare clones, one per repository. | `<data dir>/mirrors` | Absolute or relative path |
| `PUSH_CONCURRENCY` | How many git fetch or push operations run at once across all repositories. | `2` | 1 to 32 |
| `RELEASE_LIMIT` | Maximum number of releases to mirror per repository | `10` | Number (1-100) |
| `RELEASE_TYPE` | Which releases are candidates: `all` mirrors everything, `stable` skips what the source marks as a pre-release. With a filter active the release limit counts the releases that match, and releases of the other kind are removed from Gitea on the next sync | `all` | `all`, `stable` |
| `RELEASE_INCLUDE_TAGS` | Comma separated tag patterns; only releases whose tag matches are mirrored. A pattern is a glob over the whole tag (`v1.*`, `*-rc.*`, `?` for one character) or a regular expression in slashes (`/^v\d+\.\d+\.\d+$/`), matched case-insensitively. Commas inside the slashes belong to the expression (`/^v\d{1,3}\.\d+$/`) | empty | any list of globs or `/regex/` |
| `RELEASE_EXCLUDE_TAGS` | Comma separated tag patterns in the same syntax; releases whose tag matches are skipped, and an excluded tag wins over `RELEASE_INCLUDE_TAGS` | empty | any list of globs or `/regex/` |
| `RELEASE_ASSET_LIMIT` | Upload release assets only for the newest N mirrored releases. Older releases still get their notes and tag. Unset means assets for every mirrored release, `0` means release notes only. Lowering it never deletes assets already uploaded unless `PRUNE_OLD_RELEASE_ASSETS` is on | unset (all) | Number (0 or more) |
| `PRUNE_OLD_RELEASE_ASSETS` | Opt-in cleanup for what the asset limit left behind. Off, a sync only ever uploads and attachments already on Gitea stay. On, every sync also deletes the attachments of each mirrored release ranked past `RELEASE_ASSET_LIMIT`; the releases themselves and their notes stay | `false` | `true`, `false` |
| `MIRROR_ISSUE_CONCURRENCY` | Number of issues processed in parallel. Set above `1` to speed up mirroring at the risk of out-of-order creation. | `3` | Integer ≥ 1 |
| `MIRROR_PULL_REQUEST_CONCURRENCY` | Number of pull requests processed in parallel. Values above `1` may cause ordering differences. | `5` | Integer ≥ 1 |
> **Ordering vs Throughput:** Metadata now mirrors sequentially by default to preserve chronology. Increase the concurrency variables only if you can tolerate minor out-of-order entries.
## Automation Configuration
Configure automatic scheduled mirroring.
### Basic Schedule Settings
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `SCHEDULE_ENABLED` | Enable automatic mirroring. **When set to `true`, automatically imports and mirrors all repositories on startup** (v3.5.3+) | `false` | `true`, `false` |
| `SCHEDULE_INTERVAL` | Interval in seconds or cron expression. **Supports cron syntax for scheduled runs** (e.g., `"0 2 * * *"` for 2 AM daily) | `3600` | Number (seconds) or cron string |
| `DELAY` | Legacy: same as SCHEDULE_INTERVAL | `3600` | Number (seconds) |
> **🚀 Auto-Start Feature (v3.5.3+)**
> Setting either `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` triggers auto-start functionality where the service will:
> 1. **Import** all GitHub repositories on startup
> 2. **Mirror** them to Gitea immediately
> 3. **Continue syncing** at the configured interval
| `CLEANUP_RETENTION_DAYS` | Days to keep events | `7` | Number |
### Repository Cleanup
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `CLEANUP_DELETE_FROM_GITEA` | Apply the orphaned-repo action on the Gitea side too. When `false` (default), cleanup only updates gitea-mirror's own database (orphans are marked archived or removed from the repo list) and the Gitea/Forgejo copies are left untouched | `false` | `true`, `false` |
| `CLEANUP_DELETE_IF_NOT_IN_GITHUB` | Delete repos not found in GitHub (automatically enables cleanup) | `true` | `true`, `false` |
| `CLEANUP_ORPHANED_REPO_ACTION` | Action for orphaned repositories. **Note**: `archive` is recommended to preserve backups | `archive` | `skip`, `archive`, `delete` |
| `CLEANUP_DRY_RUN` | Test mode without actual deletion | `false` | `true`, `false` |
| `CLEANUP_PROTECTED_REPOS` | Comma-separated list of protected repository names | - | Comma-separated strings |
**🛡️ Safety Features (Backup Protection)**:
- **GitHub Failures Don't Delete Backups**: Cleanup is automatically skipped if GitHub API returns errors (404, 403, connection issues)
- **Archive Never Deletes**: The `archive` action ALWAYS preserves repository data, it never deletes
- **Graceful Degradation**: If marking as archived fails, the repository remains fully accessible in Gitea
- **The Purpose of Backups**: Your mirrors are preserved even when GitHub sources disappear - that's the whole point!
- Makes repository read-only while preserving all data
- **Mirror repositories**: Uses rename strategy (Gitea API returns 422 for archiving mirrors)
- Renamed with `archived-` prefix for clear identification
- Description updated with preservation notice and timestamp
- Mirror interval set to 8760h (1 year) to minimize sync attempts
- Repository remains fully accessible and cloneable
- **Sync on Demand**: Archived mirrors are still available on the Repositories page with automatic syncs disabled; use the `Sync` action to refresh them on demand.
### Execution Settings
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `CLEANUP_BATCH_SIZE` | Number of items to process per batch | `10` | Number |
| `CLEANUP_PAUSE_BETWEEN_DELETES` | Pause between deletions (milliseconds) | `2000` | Number |
## Authentication Configuration
Configure authentication methods and SSO.
### Login Page
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `AUTH_DEFAULT_METHOD` | Tab the login page opens on when both email and SSO are available. Any other value falls back to `email`. | `email` | `email`, `sso` |
| `AUTH_ALLOW_SIGNUP` | Allow email and password sign-up after the first account exists. Off, the sign-up endpoint answers 403 once a user exists; the first account can always be created. | `false` | `true`, `false` |
The login page also remembers the method last used in that browser, which wins
over this default. Anything unrecognised falls back to `email`.
- Access via local network IP: `http://10.10.20.45:4321`
- Access via public domain: `https://gitea-mirror.mydomain.tld`
- Auth requests from the IP will be sent to the domain (via `PUBLIC_BETTER_AUTH_URL`)
- Each origin requires separate login due to browser cookie isolation
**Important:** When accessing from different origins (IP vs domain), you'll need to log in separately on each origin as cookies cannot be shared across different origins for security reasons.
### Path Prefix Deployments
If you serve Gitea Mirror under a subpath such as `https://git.example.com/mirror`, set:
```bash
# BASE_URL handles the path prefix — auth URLs stay as origin only
# → Auth endpoints resolve to: https://git.example.com/mirror/api/auth/*
```
Notes:
- `BETTER_AUTH_URL` and `PUBLIC_BETTER_AUTH_URL` must be **origin only** (scheme + host). Do not include the base path — it is applied automatically from `BASE_URL`. Any path accidentally included is stripped.
- `BETTER_AUTH_TRUSTED_ORIGINS` must also contain origins only (no path).
- `BASE_URL` is applied at runtime, so prebuilt images can be reused with different path prefixes.
### Trusted Origins
The `BETTER_AUTH_TRUSTED_ORIGINS` variable serves multiple purposes:
1. **SSO/OIDC Providers**: When using external authentication providers (Google, Authentik, Okta)
2. **Reverse Proxies**: When running behind nginx, Traefik, or other proxies
3. **Cross-Origin Requests**: When the frontend and backend are on different domains
4. **Development**: When testing from different URLs
This document describes the smart force-push protection system introduced in gitea-mirror v3.11.0+.
## The Problem
GitHub repositories can be force-pushed at any time — rewriting history, deleting branches, or replacing commits entirely. When gitea-mirror syncs a force-pushed repo, the old history in Gitea is silently overwritten. Files, commits, and branches disappear with no way to recover them.
The original workaround (`backupBeforeSync: true`) created a full git bundle backup before **every** sync. This doesn't scale — a user with 100+ GiB of mirrors would need up to 2 TB of backup storage with default retention settings, even though force-pushes are rare.
## Solution: Smart Detection
Instead of backing up everything every time, the system detects force-pushes **before** they happen and only acts when needed.
### How Detection Works
Before each sync, the app compares branch SHAs between Gitea (the mirror) and GitHub (the source):
1. **Fetch branches from both sides** — lightweight API calls to get branch names and their latest commit SHAs
2. **Compare each branch**:
- SHAs match → nothing changed, no action needed
- SHAs differ → check if the change is a normal push or a force-push
3. **Ancestry check** — for branches with different SHAs, call GitHub's compare API to determine if the new SHA is a descendant of the old one:
- **Fast-forward** (new SHA descends from old) → normal push, safe to sync
- **404** (old SHA doesn't exist on GitHub anymore) → history was rewritten, force-push detected
- **Branch deleted on GitHub** → flagged as destructive change
### What Happens on Detection
Depends on the configured strategy (see below):
- **Backup strategies** (`always`, `on-force-push`): create a git bundle snapshot, then sync
- **Block strategy** (`block-on-force-push`): halt the sync, mark the repo as `pending-approval`, wait for user action
### Fail-Open Design
If detection itself fails (GitHub rate limits, network errors, API outages), sync proceeds normally. Detection never blocks a sync due to its own failure. Individual branch check failures are skipped — one flaky branch doesn't affect the others.
## Backup Strategies
Configure via **Configuration → Connections → Destructive Update Protection**.
| Strategy | What It Does | Storage Cost | Best For |
|---|---|---|---|
| **Disabled** | No detection, no backups | Zero | Repos you don't care about losing |
| **Always Backup** | Snapshot before every sync (original behavior) | High | Small mirror sets, maximum safety |
| **Smart** (default) | Detect force-pushes, backup only when found | Near-zero normally | Most users — efficient protection |
| **Block & Approve** | Detect force-pushes, block sync until approved | Zero | Critical repos needing manual review |
### Strategy Details
#### Disabled
Syncs proceed without any detection or backup. If a force-push happens on GitHub, the mirror silently overwrites.
#### Always Backup
Creates a git bundle snapshot before every sync regardless of whether a force-push occurred. This is the legacy behavior (equivalent to the old `backupBeforeSync: true`). Safe but expensive for large mirror sets.
#### Smart (`on-force-push`) — Recommended
Runs the force-push detection before each sync. On normal days (no force-pushes), syncs proceed without any backup overhead. When a force-push is detected, a snapshot is created before the sync runs.
This gives you protection when it matters with near-zero cost when it doesn't.
#### Block & Approve (`block-on-force-push`)
Runs detection and, when a force-push is found, **blocks the sync entirely**. The repository is marked as `pending-approval` and excluded from future scheduled syncs until you take action:
- **Approve**: creates a backup first, then syncs (safe)
- **Dismiss**: clears the flag and resumes normal syncing (no backup)
Use this for repos where you want manual control over destructive changes.
## Additional Settings
These appear when any non-disabled strategy is selected:
### Snapshot Retention Count
How many backup snapshots to keep per repository. Oldest snapshots are deleted when this limit is exceeded. Default: **5**.
### Snapshot Retention Days
Maximum age (in days) for backup snapshots. Bundles older than this are deleted during retention enforcement, though at least one bundle is always kept. Set to `0` to disable time-based retention. Default: **30**.
### Snapshot Directory
Where git bundle backups are stored. Default: **`data/repo-backups`**. Bundles are organized as `<directory>/<owner>/<repo>/<timestamp>.bundle`.
### Block Sync on Snapshot Failure
Available for **Always Backup** and **Smart** strategies. When enabled, if the snapshot creation fails (disk full, permissions error, etc.), the sync is also blocked. When disabled, sync continues even if the snapshot couldn't be created.
Recommended: **enabled** if you rely on backups for recovery.
## Backward Compatibility
The old `backupBeforeSync` boolean is still recognized:
│ └─ Set repo status to "pending-approval", return early
│
├─ If backup needed (always, or on-force-push + detected):
│ └─ Create git bundle snapshot
│
└─ Proceed to mirror-sync
```
## Troubleshooting
**Repos stuck in "pending-approval"**: Use the Approve or Dismiss buttons in the repository table, or call the approve-sync API endpoint.
**Detection always skipped**: Check the activity log for skip reasons. Common causes: Gitea repo not yet mirrored (first sync), GitHub API rate limits, network errors. All are fail-open by design.
**Backups consuming too much space**: Lower the retention count, or switch from "Always Backup" to "Smart" which only creates backups on actual force-pushes.
**False positives**: The detection compares branch-by-branch. A rebase (which is a force-push) will correctly trigger detection. If you routinely rebase branches, consider using "Smart" instead of "Block & Approve" to avoid constant approval prompts.
This guide covers deploying Gitea Mirror using Nix flakes. The Nix deployment follows the same minimal configuration philosophy as `docker-compose.alt.yml` - secrets are auto-generated, and everything else can be configured via the web UI.
## Prerequisites
- Nix 2.4+ installed
- For NixOS module: NixOS 23.05+
### Enable Flakes (Recommended)
To enable flakes permanently and avoid typing flags, add to `/etc/nix/nix.conf` or `~/.config/nix/nix.conf`:
```
experimental-features = nix-command flakes
```
**Note:** If you don't enable flakes globally, add `--extra-experimental-features 'nix-command flakes'` to all nix commands shown below.
## Quick Start (Zero Configuration!)
### Run Immediately - No Setup Required
```bash
# Run directly from the flake (local)
nix run --extra-experimental-features 'nix-command flakes' .#gitea-mirror
# Or from GitHub (once published)
nix run --extra-experimental-features 'nix-command flakes' github:RayLabsHQ/gitea-mirror
# If you have flakes enabled globally, simply:
nix run .#gitea-mirror
```
That's it! On first run:
- Secrets (`BETTER_AUTH_SECRET` and `ENCRYPTION_SECRET`) are auto-generated
- Database is automatically created and initialized
- Startup recovery and repair scripts run automatically
- Access the web UI at http://localhost:4321
Everything else (GitHub credentials, Gitea settings, mirror options) is configured through the web interface after signup.
| Direct GitHub | 0 min | Slow (build) | Free | Low |
| nixpkgs | Hours/days | Fast (binary) | Free | High |
| Self-hosted cache | 30+ min | Fast (binary) | Server cost | Low |
**Current approach:** Direct GitHub consumption with CI validation using Magic Nix Cache. Users build locally (reproducible via flake.lock). Consider **nixpkgs** submission for maximum reach once the package is mature.
Gitea Mirror supports push notifications for mirror events. You can be alerted when jobs succeed, fail, or when new repositories are discovered.
## Supported Providers
### 1. Ntfy.sh (Direct)
[Ntfy.sh](https://ntfy.sh) is a simple HTTP-based pub-sub notification service. You can use the public server at `https://ntfy.sh` or self-host your own instance.
**Setup (public server):**
1. Go to **Configuration > Notifications**
2. Enable notifications and select **Ntfy.sh** as the provider
3. Set the **Topic** to a unique name (e.g., `my-gitea-mirror-abc123`)
4. Leave the Server URL as `https://ntfy.sh`
5. Subscribe to the same topic on your phone or desktop using the [ntfy app](https://ntfy.sh/docs/subscribe/phone/)
**Setup (self-hosted):**
1. Deploy ntfy using Docker: `docker run -p 8080:80 binwiederhier/ntfy serve`
2. Set the **Server URL** to your instance (e.g., `http://ntfy:8080`)
3. If authentication is enabled, provide an **Access token**
4. Set your **Topic** name
**Priority levels:**
- `min` / `low` / `default` / `high` / `urgent`
- Error notifications automatically use `high` priority regardless of the default setting
### 2. Apprise API (Aggregator)
[Apprise](https://github.com/caronc/apprise-api) is a notification aggregator that supports 100+ services (Slack, Discord, Telegram, Email, Pushover, and many more) through a single API.
**Setup:**
1. Deploy the Apprise API server:
```yaml
# docker-compose.yml
services:
apprise:
image: caronc/apprise:latest
ports:
- "8000:8000"
volumes:
- apprise-config:/config
volumes:
apprise-config:
```
2. Configure your notification services in Apprise (via its web UI at `http://localhost:8000` or API)
3. Create a configuration token/key in Apprise
4. In Gitea Mirror, go to **Configuration > Notifications**
5. Enable notifications and select **Apprise API**
6. Set the **Server URL** to your Apprise instance (e.g., `http://apprise:8000`)
7. Enter the **Token/path** you created in step 3
**Tag filtering:**
- Optionally set a **Tag** to only notify specific Apprise services
- Leave empty to notify all configured services
### 3. Gotify (Direct)
[Gotify](https://gotify.net/) is a simple self-hosted push notification server.
**Setup:**
1. In Gotify, create an application and copy its token
2. In Gitea Mirror, go to **Configuration > Notifications**
3. Enable notifications and select **Gotify**
4. Set the **Server URL** to your Gotify instance (e.g., `https://gotify.example.com`)
5. Paste the **Application token**
6. Pick a **Default priority** (0 to 10; error notifications are always sent at priority 8)
The token is encrypted at rest like all other provider credentials.
### 4. Webhook (Generic)
Sends a plain JSON POST to any URL you control. Works with n8n, Home Assistant, or any service that accepts a generic webhook.
**Setup:**
1. In Gitea Mirror, go to **Configuration > Notifications**
2. Enable notifications and select **Webhook**
3. Set the **Webhook URL** to your endpoint
4. Optionally set a **Signing secret**
**Payload:**
```json
{
"title": "Mirror Failed: my-repo",
"message": "Repository my-repo failed to mirror",
"type": "sync_error",
"timestamp": "2026-08-03T02:33:29.707Z"
}
```
**Signature verification:**
When a signing secret is set, every request includes an `X-Webhook-Signature` header containing `sha256=<hex>`, an HMAC-SHA256 digest of the raw request body. Verify it on the receiving end:
| Sync success | Off | A mirror job completed successfully |
| New repositories discovered | Off | New repositories were discovered and imported; the three switches below select which ones |
| In your account | On | Repositories owned by your account |
| In an organization | On | Repositories of one of your organizations |
| Starred repositories | On | Repositories you starred, all stars in one alert |
The three group switches are sub-options of "New repositories discovered": they only apply while that switch is on, and a configuration saved before they existed counts as all three on (they are keys inside the stored settings JSON, so no database migration is involved). Each enabled group is sent once per group per discovery pass (scheduled sync, boot auto-start and the manual "Mirror Organization" sync), listing the repositories that were imported: one alert per account, one per organization, and a single "Starred" alert for every star because their owners are arbitrary. A large import is capped to the first 10 names plus an "and N more" line so one pass never floods your provider.
## Testing
Use the **Send Test Notification** button on the Notifications settings page to verify your configuration. The test sends a sample success notification to your configured provider.
## Troubleshooting
**Notifications not arriving:**
- Check that notifications are enabled in the settings
- Verify the provider configuration (URL, topic/token)
- Use the Test button to check connectivity
- Check the server logs for `[NotificationService]` messages
**Ntfy authentication errors:**
- Ensure your access token is correct
- If self-hosting, verify the ntfy server allows the topic
**Apprise connection refused:**
- Verify the Apprise API server is running and accessible from the Gitea Mirror container
- If using Docker, ensure both containers are on the same network
- Check the Apprise server logs for errors
**Tokens and security:**
- Notification tokens (ntfy access tokens, Apprise tokens) are encrypted at rest using the same AES-256-GCM encryption as GitHub/Gitea tokens
- Tokens are decrypted only when sending notifications or displaying in the settings UI
Gitea and Forgejo are pull mirrors: the app asks them to fetch from the source, and they keep the copy up to date themselves. GitHub has no pull mirror API and GitLab only offers one on paid tiers, so for those two the app runs a push engine instead. This page explains what that engine does, what it needs, and what it does not do.
Push targets are marked **beta** in the destination dropdown, like every source and destination other than GitHub to Gitea. They are tested end to end against github.com and gitlab.com, but have had less time in the field than the default path.
## How it works
For every repository whose destination is GitHub or GitLab:
1. **A bare clone is kept on disk** under `<data dir>/mirrors/<source host>/<owner>/<name>.git`, next to the SQLite database. The first run clones, later runs fetch with prune, so a branch or tag deleted on the source is dropped from the clone.
2. **The target repository is created when missing.** GitHub: under the account named in the connection card, or under an organization the mirror strategy picks. GitLab: a project under the user, or in a group; a missing top level group is created, a nested group is not.
3. **Branches and tags are pushed** with force and prune. The target always ends up matching the source, including rewritten history and deleted branches. Pull request refs, merge request refs and other host owned namespaces are not pushed; hosts refuse writes to them.
4. **The default branch is set** on the target to match the source, best effort.
5. **Statuses, mirror jobs and activity rows** are the same as for a Gitea mirror, so the dashboard, the scheduler, recovery and cleanup treat both the same way. The repository table shows the destination's icon and links to the target repository.
The scheduler runs the same fetch and push on every scheduled sync. Both steps are idempotent: an interrupted run just runs again.
## The target is overwritten
Every push uses force and prune. Anything committed directly on the target, a branch created there, a tag moved there, is replaced by what the source has on the next sync. Treat a push target as read only, the same way a Gitea pull mirror is.
## What is not mirrored
Push targets receive **branches and tags only**. The following are not available for them and show as disabled in the settings:
- issues, pull requests, labels, milestones
- releases and release assets (tags do travel)
- wiki
- LFS objects
- force push protection and the backup strategies
Several destinations per repository, and pulling anything back from the target, are out of scope. Mirroring is one way.
## Credentials
Tokens never appear in a URL and are never written to disk. Each git invocation gets an inline credential helper that reads the token from an environment variable set on that process only: the source token for the fetch, the target token for the push. The remote URL stored in the bare clone is the plain source URL.
Token scopes:
| Target | Scopes | Notes |
|--------|--------|-------|
| GitHub | `repo`, `workflow` | `workflow` is needed as soon as a repository contains GitHub Actions files, or the push is rejected. Add `delete_repo` only if cleanup should delete repositories on GitHub. |
| GitLab | `api`, `write_repository` | Pushes use the `oauth2` user name with the token, as GitLab documents. |
GitHub organizations cannot be created through the API. Create the organization first and make the token's user a member with permission to create repositories.
## Disk usage and concurrency
A bare clone takes roughly the size of the repository's git objects, so the data directory grows by about the size of everything you mirror this way. Clones are removed when the repository row is deleted, and by cleanup when it deletes an orphaned repository.
| Variable | Default | Meaning |
|----------|---------|---------|
| `MIRROR_CLONE_DIR` | `<data dir>/mirrors` | Where the bare clones live. |
| `PUSH_CONCURRENCY` | `2` | How many git fetch or push operations run at the same time across all repositories. API calls are cheap, a clone or a mirror push is not. |
One git process per repository at a time is enforced with a lock file next to the clone. A lock older than an hour belongs to a run that died and is taken over; a half written clone (no `HEAD`) is removed and cloned again.
## Large first pushes
GitHub limits the size of a single push. When the single push of all refs is refused, the engine retries in batches of 50 refs, branches first, then tags, followed by a prune. If a batch is still refused, the mirror fails with the git error in the repository's status.
## Cleanup
When the source deletes a repository, the cleanup service applies the configured action to the target through its API: archive on GitHub (`archived: true`) or GitLab (`/archive`), or delete. Deleting on GitHub needs the `delete_repo` scope; without it the cleanup run fails with a message naming the scope and the repository stays. On gitlab.com and instances with delayed deletion, a deleted project is renamed and kept for the configured number of days before it disappears. The bare clone is removed with the row.
The **Reconcile with destination** action compares a Gitea or Forgejo server with the database; it is not available for push targets, which have no server side mirror state to reconcile.
## Switching destinations
Once anything has been mirrored the destination is locked and changing it asks for confirmation, as for Gitea. Rows remember which destination they were mirrored to; a row mirrored to one host is refused on another until it is removed and mirrored again, so a token is never sent to the wrong host.
## Environment variables
```env
DESTINATION_PROVIDER=github # or gitlab
GITEA_URL=https://github.com # optional for github.com and gitlab.com; set for GHES or self hosted GitLab
GITEA_USERNAME=your-account
GITEA_TOKEN=your-token
GITEA_ORGANIZATION=your-org # optional, used by the single-org strategy
MIRROR_CLONE_DIR=/data/mirrors # optional
PUSH_CONCURRENCY=2 # optional
```
The `GITEA_*` names are kept for every destination kind so an existing deployment only has to change `DESTINATION_PROVIDER`. See [ENVIRONMENT_VARIABLES.md](ENVIRONMENT_VARIABLES.md).
This folder contains engineering and operations references for the open-source Gitea Mirror project. Each guide focuses on the parts of the system that still require bespoke explanation beyond the in-app help and the main `README.md`.
## Available Guides
### Core workflow
- **[DEVELOPMENT_WORKFLOW.md](./DEVELOPMENT_WORKFLOW.md)** – Set up a local environment, run scripts, and understand the repo layout (app + marketing site).
- **[ENVIRONMENT_VARIABLES.md](./ENVIRONMENT_VARIABLES.md)** – Complete reference for every configuration flag supported by the app and Docker images.
- **[NIX_DEPLOYMENT.md](./NIX_DEPLOYMENT.md)** – User-facing deployment guide for Nix and NixOS.
- **[NIX_DISTRIBUTION.md](./NIX_DISTRIBUTION.md)** – Maintainer notes for packaging, releases, and distribution strategy.
### Reliability & recovery
- **[GRACEFUL_SHUTDOWN.md](./GRACEFUL_SHUTDOWN.md)** – How signal handling, shutdown coordination, and job persistence work in v3.
- **[RECOVERY_IMPROVEMENTS.md](./RECOVERY_IMPROVEMENTS.md)** – Deep dive into the startup recovery workflow and supporting scripts.
### Authentication
- **[SSO-OIDC-SETUP.md](./SSO-OIDC-SETUP.md)** – Configure OIDC/SSO providers through the admin UI.
- **[SSO_TESTING.md](./SSO_TESTING.md)** – Recipes for local and staging SSO testing (Google, Keycloak, mock providers).
### API
- **[API.md](./API.md)** – Create API keys and call the app from scripts, CI pipelines and workflow tools.
### Sources and destinations
- **[SOURCE_PROVIDERS.md](./SOURCE_PROVIDERS.md)** – GitHub, GitLab and Gitea/Forgejo as sources: tokens, what each can mirror, the source lock.
- **[PUSH_TARGETS.md](./PUSH_TARGETS.md)** – GitHub and GitLab as destinations: how the push engine works, token scopes, disk usage, what is not mirrored.
The user-facing documentation lives on the website at <https://gitea-mirror.raylabs.io/docs/>, with its sources under `www/src/pages/docs/`. If you are looking for playbooks, see the MDX use cases under `www/src/pages/use-cases/`.
bun run setup # installs deps and seeds the SQLite DB
bun run dev # starts the Astro/Bun app on http://localhost:4321
```
The first user you create locally becomes the administrator. All other configuration—GitHub owners, Gitea targets, scheduling, cleanup—is done through the **Configuration** screen in the UI.
@@ -63,9 +63,11 @@ The middleware now serves as a fallback recovery mechanism:
#### Improvements:
- **Proper Drizzle ORM syntax** for all database queries
- **Enhanced interrupted job detection** with multiple criteria:
- **Enhanced interrupted job detection** with multiple criteria (see `src/lib/interrupted-job-detection.ts`):
- Jobs with no recent checkpoint (10+ minutes)
- Jobs that never wrote a checkpoint and started 10+ minutes ago (a job that just started is live, not interrupted)
- Jobs running too long (2+ hours)
- **Heartbeat during processing**: in-progress jobs start with a checkpoint and refresh it every 2 minutes, so one long item cannot make a live job look interrupted
- **Detailed logging** of found interrupted jobs
- **Better error handling** for database operations
This document details how the gitea-mirror application handles graceful shutdown during active mirroring operations, with specific focus on job interruption and recovery.
## Overview
The graceful shutdown system is designed for **fast, clean termination** without waiting for long-running jobs to complete. It prioritizes **quick shutdown times** (under 30 seconds) while **preserving all progress** for seamless recovery.
## Key Principle
**The application does NOT wait for jobs to finish before shutting down.** Instead, it saves the current state and resumes after restart.
## Shutdown Scenario Example
### Initial State
- **Job**: Mirror 500 repositories
- **Progress**: 200 repositories completed
- **Remaining**: 300 repositories pending
- **Action**: User initiates shutdown (SIGTERM, Ctrl+C, Docker stop)
### Shutdown Process (Under 30 seconds)
#### Step 1: Signal Detection (Immediate)
```
📡 Received SIGTERM signal
🛑 Graceful shutdown initiated by signal: SIGTERM
📊 Shutdown status: 1 active jobs, 2 callbacks
```
#### Step 2: Job State Saving (1-10 seconds)
```
📝 Step 1: Saving active job states...
Saving state for job abc-123...
✅ Saved state for job abc-123
```
**What gets saved:**
- `inProgress: false` - Mark job as not currently running
- `completedItems: 200` - Number of repos successfully mirrored
- `totalItems: 500` - Total repos in the job
- `completedItemIds: [repo1, repo2, ..., repo200]` - List of completed repos
- `itemIds: [repo1, repo2, ..., repo500]` - Full list of repos
- `lastCheckpoint: 2025-05-24T17:30:00Z` - Exact shutdown time
- `message: "Job interrupted by application shutdown - will resume on restart"`
- `status: "imported"` - Keeps status as resumable (not "failed")
#### Step 3: Service Cleanup (1-5 seconds)
```
🔧 Step 2: Executing shutdown callbacks...
🛑 Shutting down cleanup service...
✅ Cleanup service stopped
✅ Shutdown callback 1 completed
```
#### Step 4: Clean Exit (Immediate)
```
💾 Step 3: Closing database connections...
✅ Graceful shutdown completed successfully
```
**Total shutdown time: ~15 seconds** (well under the 30-second limit)
## What Happens to the Remaining 300 Repos?
### During Shutdown
- **NOT processed** - The remaining 300 repos are not mirrored
- **NOT lost** - Their IDs are preserved in the job state
- **NOT marked as failed** - Job status remains "imported" for recovery
- **Monitor**: Job complexity and checkpoint frequency
### Jobs Don't Resume
- **Check**: Recovery logs for errors during startup
- **Verify**: Database contains interrupted jobs with correct status
- **Test**: Run `bun run startup-recovery` manually
### Container Force-Killed
- **Check**: Container orchestrator termination grace period
- **Increase**: Grace period to 45+ seconds
- **Monitor**: Application shutdown completion time
This design ensures **production-ready graceful shutdown** with **zero data loss** and **fast recovery times** suitable for modern containerized deployments.
GitHub to Gitea is the supported default. Every other source and every other destination is marked **beta** in the dropdowns: they are tested end to end against real hosts, but have had less time in the field. The destination card has a matching **Destination** dropdown with Gitea, Forgejo (beta, same API as Gitea), GitHub (beta) and GitLab (beta); `DESTINATION_PROVIDER` sets it from the environment, and [PUSH_TARGETS.md](PUSH_TARGETS.md) covers the GitHub and GitLab targets.
Gitea Mirror pulls repositories from as many source hosts as the user connects, github.com and a GitHub Enterprise instance included. The connection card on the Configuration page is a list of sources with add, edit and remove. GitHub is the default, and GitLab and Gitea/Forgejo are available from the **Source** dropdown when a source is added or edited.
Picking GitLab or Gitea/Forgejo shows an **Instance URL** field. Leave it empty for the default instance, or enter the base URL of your own (for example `https://gitlab.example.com` or `http://gitea.local:3000/gitea`). The username and token fields hold the account and token for the selected host.
The **Test** button on the card checks the token against the selected host.
For a GitHub source, **Authentication** can also be set to **GitHub App installation**. Create a GitHub App with repository **Contents** and **Metadata** read permissions; grant **Issues** and **Pull requests** read permissions if mirroring that metadata. Install it on the repositories to back up, enter the installation account or organization under **Username**, then enter its App ID, installation ID and PEM private key in the Sources card. The private key is encrypted at rest and is not returned by the source API. The App sees only repositories selected for its installation; it cannot discover a user's starred repositories or star lists. PAT authentication remains available for those workflows.
App installation tokens expire after an hour, so Gitea Mirror stores a fresh one on the mirror before each of its own scheduled or manual syncs and turns off Gitea's independent pull schedule for App-backed repositories. Keep the Gitea Mirror scheduler enabled for ongoing App-backed mirrors. Existing PAT mirrors continue using their usual interval.
Replacing a mirror's credentials needs Gitea 1.27 or later as the destination. Gitea 1.26 and earlier, and Forgejo, ignore those fields, so App-backed repositories are refused there with an error instead of being mirrored and failing once the first token expires. Use a personal access token for those destinations. GitHub and GitLab destinations clone with a fresh token on every push and work with any version.
## What is mirrored
The mirror itself is done by Gitea's pull mirror, which treats every source as a plain git remote. The differences are in what the source API offers.
| | GitHub | GitLab | Gitea / Forgejo |
|---|:---:|:---:|:---:|
| Code, branches, tags | yes | yes | yes |
| Wiki | yes | yes | yes |
| LFS objects | yes | yes | yes |
| Scheduled sync | yes | yes | yes |
| Auto import of new repositories | yes | yes | yes |
| Add a single repository by URL | yes | yes | yes |
| Issues, pull requests, labels, milestones | yes | no | no |
| Releases with assets | yes | no | yes |
| Star lists | yes | no | no |
| Force push detection | yes | no | no |
The GitHub only rows read the GitHub API. For other sources the corresponding switches are disabled on the Configuration page, and the mirror step skips them. Releases are the exception: Gitea and Forgejo expose them at `GET /repos/{owner}/{repo}/releases`, so a Gitea, Forgejo or Codeberg source mirrors releases and their assets like GitHub does, with that instance's own token (public repositories need none).
Entire organizations also mirror without a token on every source; the next section covers public mode.
## Public organizations (no source connection)
An organization or group can be mirrored without any source account. Gitea Mirror lists the public repositories of a GitHub organization, a GitLab group or a Gitea/Forgejo organization on github.com, gitlab.com, a self hosted GitLab instance or a Gitea/Forgejo instance, and mirrors code, branches, tags, wiki and LFS objects through an anonymous mirror clone.
On the Organizations page, **Add organization** has a public mode, which is the default when no source is connected: pick the provider, fill in the instance URL for a self hosted GitLab or Gitea/Forgejo host (empty means the default instance), and enter the organization name. Saving creates a public only source row for that instance automatically, so the sources card does not have to be visited first. The row appears in the sources card with a public only indicator and can be edited or removed like any other source. New repositories added to the organization upstream are discovered on the next scheduled run.
Public mode degrades in known ways:
- **GitHub metadata is best effort.** Anonymous requests run under GitHub's 60 requests per hour limit, so a large organization can end up with partial metadata. Releases, description and topics mirror anonymously with no account at all. Issues, pull requests, labels and milestones are also fetched anonymously once any source in the account carries a token; with no token anywhere they are skipped, and the mirror itself is unaffected either way.
- **GitLab sources mirror code only; Gitea and Forgejo sources add releases.** The rest of the metadata remains GitHub only, as in the table above, and issues and merge requests from those sources stay a Not yet item with or without a token.
- **Personal auto discovery and starred repositories need a token.** Neither runs for a public only source.
- **Cleanup does not run.** Repositories deleted upstream are not detected for public only sources.
Listing and metadata go through the source API and are rate limited by the host when anonymous:
| Source | Anonymous API limit |
|--------|---------------------|
| GitHub | 60 requests per hour per IP address |
| GitLab.com | 500 requests per minute per IP address. GitLab has announced a phased reduction to 60 requests per hour for unauthenticated API traffic, planned for late 2026 |
| Gitea / Forgejo | No built in API rate limit. Instances may throttle or close anonymous API access on their own |
The mirror itself is a plain git clone and does not consume API requests.
Adding a username and token to the public only row on the Configuration page upgrades it to a regular source: private repositories, personal auto discovery and starred repositories start working, and GitHub metadata moves from the anonymous limit to the authenticated one. The public only row also enables adding a public repository by URL on that host, which needs a source row but no token.
The API accepts the same fields. `POST /api/sync/organization` adds a public organization like this:
`provider` is `github`, `gitlab` or `gitea`. `sourceUrl` is optional and defaults to the provider's default instance from the table at the top.
## Behaviour to know about
- **Each source locks once repositories are imported from it, and the destination locks once anything is mirrored.** A locked source's **Source** dropdown and instance URL, and the Gitea server URL, then show a lock note and a **Change** button. Changing them is still possible, but only after confirming a dialog that spells out what happens to the existing repositories. Removing a source that has repositories needs an explicit confirmation too. Saves that try to switch a locked source without that confirmation are refused by the API, and an environment variable that disagrees with a locked source is ignored on boot with a warning.
- **Sources run side by side.** Every connected source discovers, imports and mirrors independently, and each repository remembers which source it came from. Cleanup checks each source's own repositories, and mirroring a repository uses its own source's credentials. Repositories whose source was removed keep syncing on the Gitea side, because Gitea holds the stored clone credentials, but re-mirroring, metadata and cleanup skip them.
- **GitLab groups are flattened.** A project under `group/subgroup/project` lands in the Gitea organization `group` (with the preserve strategy) and keeps its full path as the repository's full name. The "Limit to specific groups" filter matches the top level group.
- **GitLab internal projects are treated as private** when the mirror is created, because they are not visible to anonymous users on the source either.
- **Repository names come from the URL path**, not the display name, so a GitLab project called "My Widget" with path `my-widget` mirrors as `my-widget`.
- **Without a token** only the configured user's public repositories and starred repositories are visible on GitLab and Gitea/Forgejo. Adding a public repository by URL works without a token on every source.
## Environment variables
| Variable | Description |
|----------|-------------|
| `SOURCE_PROVIDER` | `github` (default), `gitlab` or `gitea` |
| `SOURCE_URL` | Instance base URL for GitLab and Gitea/Forgejo |
| `GITHUB_USERNAME` | Username on the selected host |
| `GITHUB_TOKEN` | Token for the selected host |
These variables initialize or update the first source connection, creating it when none exists, and never switch a source that is locked. A `GH_API_URL` set for a single GitHub Enterprise deployment keeps applying to sources that point at github.com, so adding a github.com source in such a deployment sends its token to the configured Enterprise API; connect the Enterprise host as its own source instead.
See [ENVIRONMENT_VARIABLES.md](ENVIRONMENT_VARIABLES.md) for the full list.
## Not yet
Hosts without a usable API, and issues, merge requests and releases from GitLab, Gitea or Forgejo sources. Discovery preferences are shared, so filters apply to all sources rather than per source, and the GitHub and GitLab rate limit buckets are shared per provider. GitHub and GitLab as destinations shipped in 3.31.0 through the push engine, see [PUSH_TARGETS.md](PUSH_TARGETS.md).
This guide explains how to configure Single Sign-On (SSO) and OpenID Connect (OIDC) provider functionality in Gitea Mirror.
## Overview
Gitea Mirror supports four authentication methods:
1. **Email & Password** - Traditional authentication (always enabled)
2. **SSO (Single Sign-On)** - Allow users to authenticate using external OIDC providers
3. **Header / Forward Authentication** - Trust pre-authenticated requests from a reverse proxy (Authentik, Authelia, oauth2-proxy, etc.)
4. **OIDC Provider** - Allow other applications to authenticate users through Gitea Mirror
## Configuration
SSO and OIDC Provider settings are managed through the web UI in the Configuration page under the "Authentication" tab. Header / Forward Authentication is configured via environment variables only — see [Setting up Header / Forward Authentication](#setting-up-header--forward-authentication) below.
## Setting up SSO (Single Sign-On)
SSO allows your users to sign in using external identity providers like Google, Okta, Azure AD, etc.
### Adding an SSO Provider
1. Navigate to Configuration → Authentication → SSO Providers
2. Click "Add Provider"
3. Fill in the provider details:
#### Required Fields
- **Issuer URL**: The OIDC issuer URL (e.g., `https://accounts.google.com`)
- **Domain**: The email domain this provider serves (e.g., `example.com`). **This is load-bearing for account-linking trust — see [Account Linking](#account-linking) below.** Multi-domain IdPs can use a comma-separated list (`example.com,subsidiary.com`).
- **Provider ID**: A unique identifier for this provider (e.g., `google-sso`)
- **Client ID**: The OAuth client ID from your provider
- **Client Secret**: The OAuth client secret from your provider
#### Auto-Discovery
If your provider supports OIDC discovery, you can:
1. Enter the Issuer URL
2. Click "Discover"
3. The system will automatically fetch the authorization and token endpoints
#### Manual Configuration
For providers without discovery support, manually enter:
- **Authorization Endpoint**: The OAuth authorization URL
- **Token Endpoint**: The OAuth token exchange URL
- **JWKS Endpoint**: The JSON Web Key Set URL (optional)
- **UserInfo Endpoint**: The user information endpoint (optional)
### Redirect URL
When configuring your SSO provider, use this redirect URL:
Replace `{provider-id}` with your chosen Provider ID.
### Account Linking
When a user signs in via SSO whose email matches an **existing** email/password account, Gitea Mirror will try to *link* the SSO identity to that account so the user lands on the same dashboard — instead of being bounced to the login page or creating a duplicate account.
The trust model is **domain-scoped**:
- Every SSO provider you register is automatically marked `domainVerified: true` for the **Domain** field you set on registration.
- An SSO sign-in is auto-linked to an existing local account **only when** the user's email address actually belongs to that registered domain. Cross-domain emails are rejected even if the provider is registered.
- Local email/password accounts in this app are never verified (no email-verification flow exists), so we deliberately turn off `requireLocalEmailVerified` on the linker — otherwise no one could ever link.
**What this means for you:**
- Set the **Domain** field to the email domain(s) your IdP actually issues identities for. Don't set it to `example.com` if your IdP issues `@elsewhere.com` emails — auto-linking will silently refuse them.
- If your IdP allows users to self-register or claim arbitrary emails *within the registered domain*, an attacker on that IdP can absorb a local account by claiming the same email. This is a trust decision: by registering an IdP, you're vouching for its identity model for that domain.
- Multi-domain IdPs (one Authentik serving `acme.com,acquired.io`) work fine — list every domain comma-separated in the **Domain** field.
### Example: Google SSO Setup
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
1. In Okta Admin Console, create a new OIDC Web Application
2. Set redirect URI: `https://your-domain.com/api/auth/sso/callback/okta-sso`
3. In Gitea Mirror:
- Issuer URL: `https://your-okta-domain.okta.com`
- Domain: `your-company.com`
- Provider ID: `okta-sso`
- Client ID: [Your Okta Client ID]
- Client Secret: [Your Okta Client Secret]
- Click "Discover" to auto-fill endpoints
### Example: Authentik SSO Setup
Working Authentik deployments (see [#134](https://github.com/RayLabsHQ/gitea-mirror/issues/134)) follow these steps:
1. In Authentik, create a new **Application** and OIDC **Provider** (implicit flow works well for testing).
2. Start creating an SSO provider inside Gitea Mirror so you can copy the redirect URL shown (`https://your-domain.com/api/auth/sso/callback/authentik` if you pick `authentik` as your Provider ID).
3. Paste that redirect URL into the Authentik Provider configuration and finish creating the provider.
4. Copy the Authentik issuer URL, client ID, and client secret.
5. Back in Gitea Mirror:
- Issuer URL: the exact value from Authentik (keep any trailing slash Authentik shows).
- Provider ID: match the one you used in step 2.
- Click **Discover** so Gitea Mirror stores the authorization, token, and JWKS endpoints (Authentik publishes them via discovery).
- Domain: enter the email domain you expect to match (e.g. `example.com`).
6. Save the provider and test the login flow.
Notes:
- Make sure `BETTER_AUTH_URL` and (if you serve the UI from multiple origins) `BETTER_AUTH_TRUSTED_ORIGINS` point at the public URL users reach. A mismatch can surface as 500 errors after redirect.
- **Internal / split-DNS IdPs**: since v3.21.0 the SSO sign-in refuses identity providers whose hostnames resolve to private addresses (SSRF hardening in the auth library) unless the IdP's origin is trusted. Providers you register through the UI are trusted automatically, but if you register a *new* provider whose hostname resolves to a LAN IP from inside the container (common with split-horizon DNS), add its origin to `BETTER_AUTH_TRUSTED_ORIGINS` first, e.g. `BETTER_AUTH_TRUSTED_ORIGINS=https://auth.example.com`.
- Set the **Domain** field to the email domain your Authentik users actually have. Auto-linking to an existing local admin only happens for emails in that domain — see [Account Linking](#account-linking) for the trust model. (Authentik's default email scope mapping returns `email_verified: False` for OIDC clients, which is why account linking is gated on the domain match here rather than the IdP's verified-email claim.)
- If you created an Authentik provider before v3.8.10 you should delete it and re-add it after upgrading; older versions saved incomplete endpoint data which leads to the `url.startsWith` error explained in the Troubleshooting section.
## Setting up Header / Forward Authentication
Header authentication trusts a reverse proxy (Authentik, Authelia, oauth2-proxy, Traefik forward-auth, etc.) to authenticate users upstream and pass identity in HTTP headers. When enabled, Gitea Mirror reads the configured headers on each request and resolves the user automatically — no login form, no callback.
> **Important — operator-controlled by design.** Header auth is configured via environment variables only, not the UI. This is intentional: trusting a header means trusting whatever upstream sets it, and that decision belongs to the operator who controls the reverse proxy, not to a logged-in user inside the app. Make sure your reverse proxy strips these headers from inbound client requests, otherwise anyone can spoof them.
### Environment variables
| Variable | Description | Default |
|----------|-------------|---------|
| `HEADER_AUTH_ENABLED` | Master switch — must be `true` to enable | `false` |
| `HEADER_AUTH_USER_HEADER` | Header containing the username | `X-Authentik-Username` |
The defaults are Authentik-shaped, so for an Authentik proxy provider you generally only need to set `HEADER_AUTH_ENABLED=true` (and `HEADER_AUTH_AUTO_PROVISION=true` if you want first-login self-registration).
### Example: Authelia
Authelia uses different header names — override them:
```yaml
environment:
HEADER_AUTH_ENABLED: "true"
HEADER_AUTH_USER_HEADER: "Remote-User"
HEADER_AUTH_EMAIL_HEADER: "Remote-Email"
HEADER_AUTH_NAME_HEADER: "Remote-Name"
HEADER_AUTH_AUTO_PROVISION: "true"
```
Then configure Authelia's `authz` rules to protect the gitea-mirror route and inject the `Remote-*` headers.
### Behaviour and lookup order
- The middleware checks for a cookie session **first**. Header auth only fires when there is no existing session, so users who logged in via password or SSO are not affected.
- Lookup is by `HEADER_AUTH_USER_HEADER` value matched against `users.username`; if no match and `HEADER_AUTH_EMAIL_HEADER` is set, a second lookup is tried against `users.email`.
- If neither matches:
- With `HEADER_AUTH_AUTO_PROVISION=true`, a new user row is created (email defaults to `<username>@header-auth.local` if no email header is present).
- With `HEADER_AUTH_AUTO_PROVISION=false`, the request is rejected with a warning logged.
- If `HEADER_AUTH_ALLOWED_DOMAINS` is non-empty and the email header arrives, the email's domain must be in the list or the request is rejected.
### Verifying it's enabled
When header auth is active, the Authentication settings page renders a green "Header Authentication / Via reverse proxy" badge under the auth-methods status block. You can also probe `/api/auth/header-status`, which returns `{ "enabled": true, ... }` once the env vars are set.
### Security checklist
1. **Strip the headers at your edge.** Your reverse proxy must remove inbound `HEADER_AUTH_USER_HEADER` / email / name headers from client requests before re-injecting its own. Otherwise any unauthenticated client can set `X-Authentik-Username: admin` and walk in.
2. **Bind the app to the proxy only.** Don't expose Gitea Mirror's port directly to the network when header auth is on; only the trusted proxy should reach it.
3. **Use HTTPS between proxy and clients.** Headers travel in plaintext on the link they cross.
4. **Be conservative with `HEADER_AUTH_AUTO_PROVISION`.** With it off, you provision users in the UI/DB once and the proxy fills in sessions thereafter — safer for shared deployments.
## Setting up OIDC Provider
The OIDC Provider feature allows other applications to use Gitea Mirror as their authentication provider.
### Creating OAuth Applications
1. Navigate to Configuration → Authentication → OAuth Applications
2. Click "Create Application"
3. Fill in the application details:
- **Application Name**: Display name for the application
- **Application Type**: Web, Mobile, or Desktop
- **Redirect URLs**: One or more redirect URLs (one per line)
4. After creation, you'll receive:
- **Client ID**: Share this with the application
- **Client Secret**: Keep this secure and share only once
### OIDC Endpoints
Applications can use these standard OIDC endpoints:
- `profile` - User's name, username, and profile picture
- `email` - User's email address and verification status
### Example: Configuring Another Application
For an application to use Gitea Mirror as its OIDC provider:
```javascript
// Example configuration for another app
const oidcConfig = {
issuer: 'https://gitea-mirror.example.com',
clientId: 'client_xxxxxxxxxxxxx',
clientSecret: 'secret_xxxxxxxxxxxxx',
redirectUri: 'https://myapp.com/auth/callback',
scope: 'openid profile email'
};
```
## User Experience
### Logging In with SSO
When SSO is configured:
1. Users see tabs for "Email" and "SSO" on the login page
2. In the SSO tab, they can:
- Click a specific provider button (if configured)
- Enter their work email to be redirected to the appropriate provider
### OAuth Consent Flow
When an application requests authentication:
1. Users are redirected to Gitea Mirror
2. If not logged in, they authenticate first
3. They see a consent screen showing:
- Application name
- Requested permissions
- Option to approve or deny
## Security Considerations
1. **Client Secrets**: Store OAuth client secrets securely
2. **Redirect URLs**: Only add trusted redirect URLs for applications
3. **Scopes**: Applications only receive the data for approved scopes
4. **Token Security**: Access tokens expire and can be revoked
## Troubleshooting
### SSO Login Issues
1. **"Invalid origin" error**: Check that your Gitea Mirror URL matches the configured redirect URI
2. **"Provider not found" error**: Ensure the provider is properly configured and enabled
3. **Redirect loop**: Verify the redirect URI in both Gitea Mirror and the SSO provider match exactly
4. **`?error=UNKNOWN` on the homepage after a successful upstream login** (or `?error=account%20not%20linked` in development): the SSO callback succeeded but Better Auth refused to link the SSO identity to an existing local account. The most common cause is the SSO provider's registered **Domain** not matching the user's actual email domain — see [Account Linking](#account-linking). Set `BETTER_AUTH_LOG_LEVEL=debug` to see Better Auth's full callback trace (look for "User already exist but account isn't linked to ...") and confirm the diagnosis. The same symptom in production gets sanitized to `UNKNOWN` by Better Auth's error page before the redirect, which is why the visible error is opaque.
5. **`TypeError: undefined is not an object (evaluating 'url.startsWith')`**: This indicates the stored provider configuration is missing OIDC endpoints. Delete the provider from Gitea Mirror and re-register it using the **Discover** button so authorization/token URLs are saved (see [#73](https://github.com/RayLabsHQ/gitea-mirror/issues/73) and [#122](https://github.com/RayLabsHQ/gitea-mirror/issues/122) for examples).
### OIDC Provider Issues
1. **Application not found**: Ensure the client ID is correct
2. **Invalid redirect URI**: The redirect URI must match exactly what's configured
3. **Consent not working**: Check browser cookies are enabled
## Managing Access
### Revoking SSO Access
Currently, SSO sessions are managed through the identity provider. To revoke access:
1. Log out of Gitea Mirror
2. Revoke access in your identity provider's settings
### Disabling OAuth Applications
To disable an application:
1. Go to Configuration → Authentication → OAuth Applications
2. Find the application
3. Click the delete button
This immediately prevents the application from authenticating new users.
## Best Practices
1. **Use HTTPS**: Always use HTTPS in production for security
This document provides guidance on testing in the Gitea Mirror project.
## Current Status
The project now uses Bun's built-in test runner, which is Jest-compatible and provides a fast, reliable testing experience. We've migrated away from Vitest due to compatibility issues with Bun.
## Running Tests
To run tests, use the following commands:
```bash
# Run all tests
bun test
# Run tests in watch mode (automatically re-run when files change)
bun test --watch
# Run tests with coverage reporting
bun test --coverage
```
## Test File Naming Conventions
Bun's test runner automatically discovers test files that match the following patterns:
- `*.test.{js|jsx|ts|tsx}`
- `*_test.{js|jsx|ts|tsx}`
- `*.spec.{js|jsx|ts|tsx}`
- `*_spec.{js|jsx|ts|tsx}`
## Writing Tests
The project uses Bun's test runner with a Jest-compatible API. Here's an example test:
```typescript
// example.test.ts
import { describe, test, expect } from "bun:test";
describe("Example Test", () => {
test("should pass", () => {
expect(true).toBe(true);
});
});
```
### Testing React Components
For testing React components, we use React Testing Library:
```typescript
// component.test.tsx
import { describe, test, expect } from "bun:test";
import { render, screen } from "@testing-library/react";
import MyComponent from "../components/MyComponent";
Loaded 100 of 729 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.