#+title: gitbay changelog Versioning follows semver from v0.1.0. Database migrations run automatically on daemon start; upgrade notes appear per release when anything beyond "replace the binary and restart" is needed. * v1.12.0 — 2026-09-04 The store and the push path. Dashboard queries walk an index instead of sorting the table, concurrent writers wait instead of failing, expired rows are swept, and a large first push no longer forks a process per commit. - Every dashboard list scanned its table and sorted the result to take fifty rows. Reachability is correlated subqueries and cannot be indexed; the index supplies the =ORDER BY= instead, so the walk stops at =LIMIT=. On 20k issues and 20k merge requests: open issues 11.8ms to 0.8ms, open merge requests 12.1ms to 0.8ms, the review queue 15.5ms to 0.3ms, assigned issues 10.6ms to 0.04ms. The last of those also drives from =issue_assignees= rather than testing =EXISTS= against every issue. #137 - Concurrent writers serialise rather than failing. Every transaction in the store writes, and a deferred one takes the write lock at its first write, by which point another may hold it; SQLite answers =SQLITE_BUSY= and does not run the busy handler for that case, so =busy_timeout= could not help. Measured with eight concurrent read-then-write transactions, 44% of them failed. Beginning immediate takes the lock up front, where the timeout applies. #121 - =repo settings= updates no longer overwrite each other. =settings_json= is one blob, and every caller read it off a repository loaded earlier, changed a field and wrote it all back; two admins at once and the later write put back what it had read for the other's field. The read and the write happen together now, under one immediate transaction. #123 - Expired sessions and tokens are swept, and =[retention]= caps how long the audit log, the activity feed, webhook deliveries and the mail queue keep a row. Unset means forever, which is what every existing instance gets. #122 - The audit entry records flag names without their values. Secrets never reached argv — they travel on stdin — but prose did: =--body = was stored verbatim, in a table nothing pruned, for a repository that may be private. Identifiers are positional and survive. #122 - pre-receive on a require-signed repository forked a =cat-file= per incoming commit and built one JSON message holding the whole push. A 50k-commit first push forked 50k processes and held the history in memory twice. One =cat-file --batch= serves the push now, and the daemon verifies each commit as it arrives. #100 - The payloads other surfaces decode are named types in =control=. httpd had its own copies of what the build, profile and dashboard commands emit, so a field added to a command was silently absent on the page. #126 Migration 0035 adds three indexes; it runs on daemon start. Two changes in behaviour rather than configuration. A heavily contended write now blocks for up to =busy_timeout= (five seconds) instead of returning an error immediately — waiting is the point, but a caller that treated the error as normal will see a pause instead. And anything parsing =audit --json= for command arguments will find flag values gone; the flag names and the positional arguments are still there. * v1.11.0 — 2026-09-04 The web catches up with the rest of the forge: search across the instance, a notification inbox, a compare view, linkable diff lines, and a pass over contrast, heading structure and the stylesheet. - Notifications are no longer mail or nothing. An =inbox= table holds a row per recipient whether or not the instance has SMTP; =notifications list= reads it, unread by default, and =notifications read ... | --all= clears it. =repo watch= adds you to a repository's notifications and =repo unwatch= mutes it, with a mute beating every other reason to be told, including owning the repository or having written the thread. The unread count rides on =dashboard= and in the web rail. #113 - =search= matches repository paths, descriptions and topics, and issue and merge request titles, across everything the caller can read; =/search= renders it with a field in the rail on every page, and an anonymous visitor gets the public rows from the same query. Titles only — body search is #114. #118 - =/{owner}/{repo}/compare/{a}...{b}= shows what one ref adds on top of another. Diff rows, files and review threads carry ids, so a line can be linked to. The issue and merge request lists page at fifty with the cursors the commands already emit. #118 - CODEOWNERS gating is a setting rather than a file that happens to exist: =repo settings require-codeowners on|off=, off by default, still independent of =require-approvals=. A repository can carry the file as documentation of who to ask without it gating merges. #142 - A failed form action's reason rides a one-shot cookie instead of =?e==, so it no longer survives a reload or lands in history. #119 - A diff cut at 4 MiB cuts on a line boundary and says so, the diffstat says its counts are partial, and a merge request's commit list says "first 100 of N". #117 - Page templates parse once with the layout at start-up, so a template that does not parse fails the process rather than the first visit to its page. #116 - Markdown headings carry ids, matching org pages, and the stylesheet serves a hash ETag with a day's lifetime and answers a matching =If-None-Match= with 304. #132 - Dark-mode buttons carry a visible edge (a 1 px border was 1.3:1 against the 3:1 non-text contrast floor), and a stored label colour is held between 0.12 and 0.28 relative luminance so it clears 3:1 on both grounds while keeping its hue. #120 - Accessibility: the pin star is decoration and the word carries the meaning, the refs and release asset tables have scoped column headers, state filters carry =aria-current= rather than marking the active one by colour alone, and a rendered README or wiki page's headings move down one level so they sit under the page's own. #133 - The stylesheet drops rules no template reaches, folds two off-scale values onto the type scale, and resolves the classes templates asked for and it never defined. #131 - On a phone the tab bar wraps to a second row instead of scrolling sideways with its scrollbar hidden, and the landing page says in three sections what a reader, a writer and a reviewer can do. #134 Migration 0034 adds =inbox= and =repo_watchers=; it runs on daemon start. Reinstall the CLI for =search=, =notifications= and =repo watch/unwatch=. Two upgrade notes beyond replacing the binary. A repository that relied on a CODEOWNERS file gating merges by its presence alone loses that gate until =require-codeowners= is set: nothing back-fills it, since the database cannot say which branches carry the file. And =search= and =notifications= are now reserved top-level routes, so a user or organization already holding either name keeps it but its profile page is shadowed by the route. * v1.10.0 — 2026-09-04 Every command parses its arguments the same way, the web answers by exit code, and a restart no longer waits on idle connections. - One flag parser for every command: an unknown flag, a missing value or too many arguments is exit 2 with the command's usage, everywhere. =mr review --approve --bogus= used to report repository =--bogus= as not found. #96 - =--json= is honoured by the dispatcher's own refusals, so a script told no gets the envelope. #109 - A store failure is exit 1 and not-found is exit 3; both used to be usage errors. #107 - Web form actions answer by exit code: the 404 page for a thing that does not exist, the page with the message for anything else. #106 - Inside a clone, =gitbay mr create= takes the checked-out branch as =--source=; =gitbay --help= is the server's reference for the noun, flags included. #101 #130 - Shutdown closes idle SSH connections at once and drains only sessions mid-command; a store failure during key lookup no longer counts as a bad key against the auth limiter; the CLI says on ssh's exit 255 that a burst may have tripped it. #141 - A merge request head the target already contains is recorded as merged instead of refused; a fork is created with its parent in one insert; a failed transfer revert is reported; the repository quota is checked under a lock. #108 - Issues and merge requests share their comment, reference and author-or-write code. #110 - The payloads the web and clients decode are named types: =Created=, =MRCreated=, =IssueShow=, =MRShow=. JSON unchanged. #126 - Tests: parse failures per command, non-ASCII paths (which found =ls-tree= returning octal escapes, fixed with =core.quotepath=off= for every git the server runs), concurrent pushes. #129 Replace the binary and restart, and reinstall the CLI. No migration. * v1.9.0 — 2026-09-03 The runner is no longer an admin, the web no longer reaches around the registry, and the CLI stops paying a handshake per command. - =keys add --scope runner= confines a key to =runner next/log/done= and read-only git; the runner commands accept that scope or an admin. The systemd drop-in sandboxes the runner process. gitbay.org's runner now polls as a non-admin =ci= account scoped to one repository. #92 - The web's repo create, issue create and edit, MR edit and both comment forms dispatch the command the CLI runs, so the repository quota, the archived-repository refusal, notifications, the body format and the audit entry hold from a browser. #93 - The CLI shares one SSH connection per instance (=ControlMaster=, five minutes idle): a command costs a round trip instead of a handshake, 0.4 s instead of 4 s from a distant laptop. =no_multiplex = true= on an instance opts out. #94 - A disabled account is refused on every surface, and disabling revokes its API tokens along with its sessions. #95 - CODEOWNERS gates whenever the file exists on the target branch, not only under =require-approvals=. #99 - The HTTP listeners have header and idle timeouts, and SIGTERM drains in-flight requests and SSH sessions before exit. #104 #105 - =git archive= runs under a two-minute deadline and a 512 MiB cap. #124 - Every git invocation ends option parsing before the ref, so a ref shaped like an option is a bad revision and never a flag. #135 - The admin noun is gated in the dispatcher as well as in each handler; registry tests cover that and =ReadsStdin=. #127 - An e2e test runs every =ReadOnly= command against a populated instance and fails on any row it changes. #97 - =http.trusted_proxies= attributes proxied API requests to the last untrusted =X-Forwarded-For= hop for rate limiting; =email add= is capped at five codes an hour per account. #136 - A merge request head is built in the target repository, at =refs/merge-requests//head=, so a fork's merge request has =ci/= statuses for =require-checks= to gate on. A head from another repository is built without the target's secrets. #98 - Triggers refuse deleting a user or organization that still owns repositories, whatever path the delete takes. #136 - The stylesheet's fonts are served again, and directory crumbs on blob and blame pages link to the tree. #102 #103 - The Threat-Model wiki page covers the runner. #138 Replace the binary and restart, reinstall the CLI, and =make deploy-runner=: the runner fetches merge request refs before checkout. Migrations 0032 and 0033 run on start. The daemon host needs git 2.24 or newer for =--end-of-options=. Operators running =gitbay-runner=: give it a non-admin account with a key added by =keys add --scope runner=, remove the admin key it held, and =make deploy-runner= to install the sandboxed unit drop-in; an admin key keeps working meanwhile. * v1.8.1 — 2026-09-03 Markdown and org files render when opened. - A =.md=, =.markdown= or =.org= file renders on its page the way a README does on the directory page, through the same renderer with relative links resolved against the file's directory. =source= in the action bar, or =?view=source=, shows the text as before. Every other file is unchanged. #88 - The e2e harness waits for the HTTP and git listeners as well as SSH before a test starts; a test whose first act was an HTTP request could be refused, which failed two otherwise green runs. #91 Replace the binary and restart. No migration. * v1.8.0 — 2026-09-03 The tracker's lists narrow, labels have colours you set, and the CLI's help is the server's. - =issue list= takes =--label=, =--assignee=, =--author= and =--milestone= (a title, or =none= for issues with no milestone); =mr list= takes =--author= and =--milestone=. The store narrows in SQL, and the web lists take the same names as query parameters and show each active filter with a link that drops it and keeps the rest. Both lists had taken =--state= and nothing else, so the web's filtering could never match the CLI's. #84 - =label list= shows a repository's labels with their colour and how many issues carry each; =label set