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