#+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.13.2 — 2026-09-04 The first SonarCloud scan's findings: eight fixed, the rest triaged and dismissed with reasons. - A pages directory redirect could leave the site. Both redirects passed the raw request path to =http.Redirect= while the cleaned path sat a line above; =net/url= keeps a leading =//=, and Go emits =Location: //evil.example/= unchanged, which a browser reads as protocol-relative. Reaching it needed a public repository named like a host under the subdomain's own owner — repository names permit dots — so it was narrow rather than impossible. The destination is built from the cleaned path through =url.URL= now, which also settles the two spellings the first fix missed: a =..= that escapes to the root, and a backslash, which browsers following the WHATWG rules treat as a separator. #153 - The runner's default workspace was =/gitbay-runner=: a fixed name in a world-writable directory, created with =MkdirAll=, which succeeds against a directory whoever already owns it. bay1 was never exposed — its unit names a workspace — but the default is what anyone running the binary by hand gets, and this is the process that clones repositories and exports build secrets. The default moves under the user's cache directory, the workspace is created 0700, and a symlink or a directory owned by someone else is refused. One we own that is merely too permissive is tightened rather than refused, since every runner before this one made it 0755 and refusing would take the runner down on upgrade. #153 - Logout and flash consumption expire their cookies with the attributes the setting calls used. Deletion worked either way; the difference was a reviewer's puzzle, and four findings. #153 - The topics-remove field has a label. A placeholder is not an accessible name. TestEveryInputHasAnAccessibleName is the guard that was missing, and it knows both associations — six inputs use ==, which is as good as for/id. #153 - git and ssh are resolved once at start-up rather than searched on every spawn, and gitbayd refuses to start when one is absent instead of failing on whichever request first needed it. This was 74 of the 118 findings; a dashboard that is mostly permanent noise is one nobody reads. #153 Also: SQLite migrations are excluded from analysis. They were read as PL/SQL, where ='' is NULL=, so =WHERE col = ''= on a =NOT NULL DEFAULT ''= column — correct SQLite, and the shape used throughout — read as a null-comparison bug. Replace the binary and reinstall the CLI. No migration. A runner whose workspace is group- or world-readable will have it tightened to 0700 on next start, and will refuse to start if that workspace is a symlink or belongs to another user. * v1.13.1 — 2026-09-04 A command that reads its payload from stdin says so, and SonarCloud runs alongside the vulnerability scan. - =repo secret set= blocked with nothing printed, which is indistinguishable from a hung connection, and pressing Enter did not end it because the server reads to EOF — so it looked the same before and after the value had been typed, and the secret echoed into the scrollback on the way. A terminal is now told what is wanted and that Ctrl-D ends it; a secret is read without echo and takes one line, so Enter is enough. Piped input is unchanged, byte for byte: =printf %s "$TOKEN" | gitbay repo secret set ...= still sends exactly the token. Applies to =keys add=, =auth pgp add=, =repo deploy-key add= and =release asset add= as well; public keys keep echoing, since they are public. #150 - A =sonar= CI job reports to SonarCloud, alongside =vuln=. Report-only: it does not gate a build, unlike the vulnerability scan. A merge request from a fork builds without secrets by design, so the job says why it is skipping there rather than failing on a missing credential. Only =SONAR_TOKEN= is secret and it is a build secret; the organization, project key and host are checked in. Ref #149 Replace the binary and reinstall the CLI. No migration. * v1.13.0 — 2026-09-04 Collaboration. A review is composed and submitted as one thing, a merge request can say it is not asking yet and can show what changed since you last looked, issues are searched by their text, and the events half the mutations never recorded now exist. - A review is composed as a unit. =mr diff-comment --pending= holds a comment back; =mr review= publishes the batch with the verdict and says how many went with it; =mr review --discard= throws away what was never submitted. A pending comment notifies nobody when written — the review is the announcement, and it names its own size — and does not gate a merge, since a thread only its author can see is one nobody else could resolve. #111 - =mr create --draft=, =mr draft=, =mr ready=. A draft does not merge and does not appear in anyone's review queue; marking it ready is the request for review. Draft is a flag rather than a fifth state, so every =state = 'open'= rule still means what it did, and the merge refusal is unconditional: every other gate is a setting an admin turns on, and this one is the author's own statement about their own work. #111 - =mr range-diff= shows what changed between two revisions of a merge request, and =mr revisions= lists them. A push stales every review and nothing said what had moved; a diff of the two heads cannot answer it, and the previous head was overwritten in place. Each side of the comparison carries the merge base it had at the time, since measuring both against today's would attribute every commit that landed on the target in between to this merge request's author. #111 - Issues and merge requests are searched by their title and body, over FTS5. =issue list --search= and =mr list --search= narrow one repository; the instance-wide =search= reaches bodies now, and both list pages carry a search box. What someone types is quoted term by term, so an FTS5 operator — =c++=, =AND=, a lone quote — is a word to match rather than a syntax error. #114 - Ten mutations that changed a repository silently now record an event: =mr.closed=, =mr.reviewed=, =mr.edited=, =mr.retargeted=, =mr.draft=, =issue.edited=, =issue.labeled=, =issue.assigned=, =issue.milestoned=, =mr.milestoned=, =release.deleted=. =mr close= and =issue edit= also notify participants, which they did not. The full list is on the API wiki page and =webhook add --events= refuses a name that is not on it, since a subscription to a typo would never fire and nothing would say so. #112 - =gitbay-runner -jobs N= runs N builds at once. Claiming was always a single transaction that selects and updates, and each build already worked in its own directory, so several workers were safe the whole time; the runner never used more than one. #115 - A merge request replayed from a migration bundle has a diff. The bundle carried no head at all, and =mr diff= resolves through the head ref, so a merged merge request — which has no source branch left — could only fail. Re-running an import after the git push sets the head it could not set the first time. The GitHub import fetches =refs/pull/*/head=, which a mirror made with the default refspecs does not carry. #128 - The review loop is driven end to end by three accounts in the test suite: draft, ready, thread, approval, resolve, merge, with approvals, CODEOWNERS and require-resolved all on at once. Every one of those had its own test and none of them met. #139 - A bare =go test ./...= says the timeout is too short instead of panicking eleven minutes in and blaming whichever test was running. =make test= is what CONTRIBUTING asks for now. #143 Migrations 0036 through 0039 add the search index, the draft flag, pending review comments and merge request head history; 0036 and 0039 backfill from what is already there. They run on daemon start. Two things this does not do. There is still no way to ask a particular person for a review — the queue is computed from involvement, so a collaborator with write access who has not touched a thread hears nothing (krz/gitbay#145). And a CI build still runs as the runner's own user with no container; the runner on this instance is scoped to one repository, which is what keeps that narrow (krz/gitbay#144). Both are v1.14.0. * 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