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.