#+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. * Unreleased - ~webhook add~'s ~--secret~ now reads the signing secret from stdin (~--secret -~) instead of taking it as a command-line value (#284). - The builds page's status badge section gives an org-mode snippet beside the Markdown one, for a README.org (#299). - API tokens on the settings page: create with a scope and optional expiry, shown once; list; revoke with the name typed back; the registered page's next steps as a numbered list (#264). - A wiki link to an existing non-page file (an .svg, .txt, .pdf) now resolves to the raw route instead of 404ing against the page route (#283). - The new-issue form takes labels, milestone and assignee in one step for writers; a Discussion heading sits before comment threads; the build page's live note says the page updates itself; and the rail and the phone More menu render from one list (#271). - The new-issue form keeps milestone and assignee through preview and a refused create, the way it already kept title, body and labels; a refused create re-renders the form with the draft and the refusal instead of an error page (#271). - The merge request range-diff page renders a bad =from=/=to== query parameter inline instead of 404ing; only an unknown merge request 404s (#271). - Empty states on the web state the fact instead of a CLI command, and drop "yet" on a finished item; the merge request list offers a New merge request link, a fork link, or a sign-in prompt depending on what the visitor can do; and the search page's scope caption is always visible, not only before a first search (#270). - Issues, milestones, org milestones and releases drop the CLI command from their empty states too, matching the rest of the register: a link to the web form that does the thing when one exists, otherwise just the fact (#270). - The merge request list and compare page offer New merge request to a reader who owns a writable fork of the repository, not only to a writer (#270). Credentials and sessions: revocation, delegation, expiry and an idle timeout (#256, #257, #276, #277). *Upgrade note.* =token create= makes a =read= token unless given =--scope full=. A script that mints a token and then writes with it must add =--scope full=. Existing tokens keep their scope. *Upgrade note.* Upgrade the instance before the CLI: an older server refuses the CLI's leading =--path== argument as an unknown command, for the eighteen commands whose CLI path differs from the registry's (the =gitbay auth ...= commands and =repo topics list=). *Upgrade note.* gitbayd needs =server.secret_key_file= (default =/etc/gitbay/secret.key=) and refuses to start without it. Before replacing the binary, run =gitbayd admin secrets init= as root and =chown gitbay:gitbay /etc/gitbay/secret.key= (=deploy/install.sh= does both when the file is missing). The first start seals the stored secrets. Back the key file up separately: =admin backup= archives do not carry it (see the Admin wiki, "Secret key"). Downgrading to an earlier release after values are sealed is not supported: an older gitbayd reads a sealed value's =gbs1:...= prefix as the literal secret. *Upgrade note.* Archives now carry a directory entry for every directory, so a bare repository whose refs are all packed restores as a repository. An archive written before this release lacks those entries; if extracting one leaves a repository's =refs/= directory missing, =gitbayd admin backup --verify = names it, and =mkdir -p /repos//.git/refs= fixes it. - A token with a =--ttl= is refused on every command that creates a credential: tokens, keys, deploy keys, runner keys, login links, invites, accounts and verified addresses (#257). - Tokens and SSH keys record the token they were created through. =token revoke = lists what it created; =--created= revokes those too (#257). - Removing an SSH key, a deploy key, or disabling an account closes the connections the key opened, a push in flight included (#256). - =keys add= and =repo deploy-key add= take =--ttl=; an expired key is refused at authentication, and an open connection on it closes within 15 seconds. An expiring key cannot create credentials, like an expiring token. =keys list= and =repo deploy-key list= gain =USED= and =EXPIRES= columns, after the label (#277). - =token list= at a terminal shows a future expiry as a time, not "just now" (#286). - Browser sessions end after twelve hours without a request, and after seven days as before. Sessions open at upgrade get a fresh twelve hours. =web sessions list= shows when each was last used (#276). - =web login= over SSH refuses a sixth link in an hour, the same bound the login page's mailed links have (#278). - The HTTPS listener refuses TLS below 1.2, in both certificate modes (#281). - Mail to a non-local relay requires TLS by default; a relay that does not offer STARTTLS gets no mail unless =mail.require_tls = false= restores the old behaviour. =mail.tls = "implicit"= speaks TLS from the first byte, for relays on port 465 (#280). - Mirror sync resolves the host, checks the addresses and connects git only to them, with redirects off; a URL that now resolves to private space, or is not http or https, fails the sync with the reason on =repo mirror list=. A mirror of a renamed repository that redirects fails until its URL is updated. Sync ignores the system and global gitconfig. Needs git 2.37 or later: with an older git no mirror syncs, and each records why (#279). - Webhook and mirror targets in 100.64.0.0/10 or on a multicast address are refused, as private addresses are (#279). - The hook socket is mode 0600 and, on Linux, refuses a peer with another uid; each receive-pack gets its own token from sshd, stored hashed (migration 0063), and the hook must present it before the daemon acts. *Operators:* deploy with no push in flight, since a receive-pack started by the old daemon has no token and its post-receive will be refused by the new one (#282). - Audit rows are hash-chained, each carrying the SHA-256 of the one before it (migration 0064), and the daemon logs a copy of every row it writes to its journal. =gitbayd admin audit verify= prints the row count and the last id and hash, and exits 1 naming the first row that was edited or whose predecessor was removed. The chain is unkeyed: someone who can write the database can recompute the later hashes, and verify catches an edit only when they were not recomputed. Comparing verify's last id and hash with the journal is the check for any change, including removing the newest rows (#275). - Refused mutating commands (exit 3 or 4) are audited as =refused =, and refused pushes as =refused git-receive-pack=, keeping flag names and the target but no values; ten a minute per account and 600 across the instance, counted per process, past which one =refused.throttled= row stands for the rest of the minute. Under =ssh.mode = "system"= each =gitbayd shell= connection counts separately (#275). - Pushes refused in pre-receive (a branch or tag rule, a release anchor, an unsigned commit) are audited as =refused push= with the repository and ref names, and hook socket requests failing the peer or push-token check as =refused hook=, under the same caps (#275). - Audit retention deletes by id, up to the newest row older than the retention, so a clock step back cannot leave a gap in the chain (#275). - =dashboard= and =feed= print activity as sentences (=cmc opened issue krz/gitbay#12=) instead of raw event payloads, and a labelled event names its labels there and on the web feed. An issue assigned to you appears only under "assigned to you": the =open_issues= field of =dashboard --json= no longer includes issues assigned to the caller. =notifications list= names =--all= when only read items remain (#265). - Help and usage print the command the way the caller typed it — =usage: gitbay auth keys remove =, not the registry's own path. =auth --help= lists the email and API token commands alongside keys and PGP. A bad-flag error prints the same prefixed usage line a wrong-argument refusal does. Outside the CLI a usage refusal reads =usage: ssh git@ ...= on every surface, including the error text of the web UI and the JSON API (#267). - An unregistered SSH key is refused with its own fingerprint and the real host, and both the web and ssh paths to register (#268). - =issue create= takes =--label= (repeatable), =--milestone= and =--assignee= (repeatable), setting them in the same call instead of a separate one per field (#268). - =mr show= pluralizes multi-row section headings with counts: =commits (7):=, =checks (2):=, =reviews (3):= (#268). - =repo readme [--ref ]= prints a repository's README, picked the same way the web page picks one (#268). - =repo show='s mirror table truncates =LAST SYNC= to the second, like every other timestamp in a view (#268). - A =-- foreign_keys: off= migration's =foreign_key_check= now runs inside the migration's own transaction, before commit, so a violation rolls the migration back instead of leaving the bad schema and =user_version= already persisted; the web pin and watch buttons dispatch through =repo pin=/=unpin= and =repo watch=/=mute=/=unwatch= instead of writing the store directly, so a refusal reaches the viewer as a message instead of being dropped, and the watch button now cycles three states — default, watching, muted — instead of two; the response that consumes a login link's =?token== sends =Cache-Control: no-store=, so no intermediary keeps a copy of the single-use URL; and wiki documentation fixes: API.org clarifies token commands work on the API, Parity.org documents batched review and web watch/pin dispatch, Threat-Model.org documents the login-link URL exception (#261). - The account settings page quotes the CLI and SSH command forms that actually resolve; a test runs every command a web page quotes against the CLI and control registries so a renamed command fails CI instead of shipping a dead instruction (#263). - The web merge request page lists all revisions with a "compare to previous" link on each revision after the first, so a reviewer whose approval a force-push staled can see what changed without leaving the browser (#269). - CI secrets, webhook secrets, mirror tokens and push device tokens are stored sealed with AES-256-GCM (#273). =gitbayd admin secrets init|rotate|check=. - Untrusted builds (merge requests from forks) get a fresh HOME removed after the build and no secrets; trusted builds keep a per-repository home under =/trusted-home=. Deploy gitbayd before the runner; the old shared homes under the runner's workdir can be deleted. (#255) - =status set= refuses =ci/= contexts, which belong to the instance's builds. Build results are reused only from trusted builds on the same image. =repo settings require-contexts= names status contexts that must report green; setting any turns require-checks on, and one not yet reported counts as pending. (#258) - A runner polling over loopback gives its podman builds =GITBAY_SSH= at =169.254.1.2=, pasta's address for the host, with the port when it is not 22, since under pasta the container holds the host's public address. An nftables table limits what the runner's user reaches on the host to =127.0.0.1:22=, DNS on loopback, and public 22, 80 and 443. The runner unit now requires the egress unit: run =deploy/runner-podman-setup.sh= (it installs nftables) before =make deploy-runner=. Deploy gitbayd, then the runner, after validating on a scratch repository per the CI page. (#260) - =build show= names a failed build's step and duration; =build log --step |failed --tail = reads one step's output or the last lines of the stored log. Deploy gitbayd before the runner: an older server refuses the runner's =--step= and =--reason= (exit 2), and its failed builds stay running until the reaper fails them. (#266) - =gitbayd admin backup= encrypts archives to =[backup] age_recipients= when set (#274); =--verify= takes =--identity =. Archive names gain =.age=; the shipped backup scripts and monitor match both. - A full backup holds =/backup.lock= from its database snapshot to its last repository; =repo delete=, =repo rename=, =repo transfer=, =admin repo delete= and =org rename= refuse with "a backup is running" while it runs. =--verify= now also runs =git fsck --connectivity-only= on each archived repository and names any that fail. (#259) - A full backup archives each repository's HEAD, =refs/= and =packed-refs= before its objects, so a push during the backup cannot leave an archived ref naming objects the archive lacks. The exception is git's own automatic gc after a push repacking during the walk: the archive can then miss objects, and =--verify= reports it. (#259) - =gitbayd admin gc= and =admin mr prune= refuse with "a backup is running" during a full backup. A pack or loose object that git's automatic gc removes while the backup walks is skipped instead of failing the run; =--verify= reports it if a ref needed it. (#259) - =gitbayd admin backup= and =--verify= no longer need the secret key file: sealed values are copied as they are. A missing database is refused instead of created. (#259) - =gitbayd admin backup= refuses an =--out= inside =server.root=, and removes the snapshot directories and temporary archives a killed run left beside its archive once they are a day old. (#259) - =gitbayd admin backup --verify= runs fsck with =--git-dir=, so a directory that is not a repository fails instead of git checking an enclosing one, and does not extract =objects/info/alternates= or a repository's =commondir=, so neither can point fsck at another repository on the host. (#259) - =gitbayd admin secrets init= and =rotate= hold an flock on =.lock=, so two runs at once serialize. (#273) - Git pack generation (clones, fetches, =git archive --remote=) over SSH, smart HTTP and git:// now shares one concurrency budget: =limits.pack_concurrency= (3), =pack_per_principal= (2), =pack_queue= (32) and =pack_queue_wait= (60s). Past the queue an SSH client sees "the server is busy…" and exits 1, HTTP gets 503 with =Retry-After: 30=, and git:// gets an =ERR= line. *Operators:* the defaults are tuned for a four-core host; set the three counts to -1 to turn the limit off. A running clone is killed when no write to its client completes for two minutes: a client reading below about 550 B/s, or an HTTP request body that takes over two minutes with nothing written back, is cut. Ref listings, pushes and =repo download= are unaffected. Under =ssh.mode = "system"= SSH clones are not counted, since each session is its own process (#262). - Anonymous clones are counted per IPv4 address or IPv6 /64, and together hold at most =pack_concurrency= − 1 slots: an account can take the last slot when it is free, and anonymous clients cannot hold it (#262). - A request turned away by the pack limit logs a warning naming the transport and whether the client was signed in, never its address, at most once a minute per transport (#262). - Web archive downloads (=/{owner}/{repo}/archive/{ref}.tar.gz=) take a pack slot, answer 503 with =Retry-After: 30= when none is free, and are killed when the client leaves or stops reading (#262). * v1.36.0 — 2026-09-23 Terminal output for the CLI (#254). *Upgrade note.* Upgrade the instance before the CLI: an older server refuses the CLI's leading =--term= argument as an unknown command. Against an older instance, set =GITBAY_TERM=off= and the CLI sends no terminal size. - At a terminal, lists print under a header, fitted to the width, with states in colour and relative ages; the next page is a command on stderr. Piped output keeps the same tab-separated rows, with timestamps as RFC3339 to the second; =repo log= rows are tab-separated (were fixed-width) and =repo settings show= keys print as fields (=require mr=, not =require_mr:=). - =show= commands print a title line, aligned fields, the body rendered from markdown or org, events one per line and comments under a rule, through a pager when longer than the screen. - Help describes every flag, with defaults and examples, and =gitbay --help= groups the commands under WORK, REPOSITORIES, YOU and INSTANCE. =help --json= adds =flags= and =examples=. - =release list= takes =--limit= and =--cursor=, and leaves the title empty when it repeats the tag. =mr revisions='s one-revision note moved to stderr. =notifications device add= prints =registered device =. =dashboard= prints =none= under an empty section. The CLI sends =--term=[,color]= as the first argument on the SSH command line, since OpenSSH's multiplexed sessions do not forward a session's =SetEnv=; the server reads it only there, and ignores it over HTTP. Stock ssh opts in with =ssh git@gitbay.org -- --term=120,color issue list krz/gitbay= (the =--= keeps ssh from reading it as its own option) or =-o SetEnv=GITBAY_TERM=120,color=. Operators running the system-sshd forced command add =AcceptEnv GITBAY_TERM= to =sshd_config= for the =SetEnv= form; the =--term= argument needs no sshd configuration. * v1.35.1 — 2026-09-23 The landing page's recording (#253). - The landing page plays a recording of signup to release from a terminal on gitbay.org, muted with controls, in place of the merged merge request picture. The quickstart block drops its comment. The tapes and the render script are in =docs/demo/=. - =/static/img/= serves gif, webm and mp4 with their own types and answers Range requests, which Safari needs to play video. - The Quickstart wiki page shows the recording as a gif. - The footer's iOS link goes to the App Store listing. * v1.35.0 — 2026-09-23 Following a running build (#250, #251), and the web interface guidelines review (#249). - =build log --follow= prints the stored log, then output as the runner sends it, then =build = on stderr once the build ends; the exit code is 0 whatever the outcome. The build page does the same without JavaScript while a build is queued or running: the log streams into the page and ends with "build finished: ". =?follow=0= and HEAD render it once. - A follow ends when its reader leaves: a closed SSH channel (Ctrl-C over the CLI's shared connection included) or a closed page. A follow of a build still queued after ten minutes ends and says so, since nothing reaps a queued build. An account holds eight follows at once; signed-out viewers share one account's eight. - A follow re-checks read access every two seconds by repository id, reloading the account: a repository made private or an account disabled ends it as not found; a rename does not. - A restart ends open follows before the drain, with "gitbay is restarting; follow the build again in a moment" on every surface, instead of holding the drain for 30 s. Other requests, git transport included, drain as before. - Over the JSON API the command answers when the build ends, with the whole log. - Typed-name confirmation on removing an org member, removing a team member, revoking a team's repository grant, and promoting an account to admin. The team forms are split so the confirm field sits beside the removal only. - Registration's email field is an email input; email, username, one-time-code, SSH key and identifier fields carry =autocomplete=, =autocapitalize= and =spellcheck= as fits. The merge request close field accepts the =!12= its placeholder shows. - The refs page's compare inputs are named by their visible labels; the line-comment link names its line. =theme-color= matches the rail. - Behind a reverse proxy, turn response buffering off for the build page (=X-Accel-Buffering: no= covers nginx), or the live log arrives only when the build ends. * v1.34.1 — 2026-09-21 Three findings from an external review of the web UI (#248). - Explore is a rail square at every width. It was =.railopt=, so it dropped below 34rem into the More menu, which exists only for a signed-in viewer: a signed-out phone had no route to the listing from any page. Six squares and the mark are 308px, inside a 320px phone. - =/register= answers "no key yet?" in place, with the =ssh-keygen= and =cat= lines behind a disclosure, instead of linking out to the wiki over a half-filled form. The link also hardcoded =gitbay.org=. - An empty =/search= says what to do next — fewer words, the same query across every kind, or the listing — rather than repeating the count line's "no matches". * v1.34.0 — 2026-09-21 The profile's tabs, reworked. - The bar reads About, Repositories, Bookmarks, Snippets, and People for an organization's admins. =/{owner}= is About; the rest hang off =/-/=. A tab nobody may open is not offered and its URL is a 404. - The activity graph moved inside About, with a log of the newest thirty events under it. The log counts what the graph over it counts: =UserPublicEvents= keys on the actor for a user, matching =ActivityByDay=, while an organization keeps =OwnerPublicEvents=. Both are public repositories only. =activity.atom= is still the feed that goes back further. - Bookmarks are a tab on your own profile. =repo bookmarks= takes no owner and lists the caller's, so the tab is not offered on anyone else's and its URL is a 404 there. =/bookmarks= redirects to it. - The snippet list renders inside the profile rather than on a page of its own, so it carries the header and the tab bar like every other section. A snippet itself is still its own page. Eleven web findings, mostly alignment and affordance (#247). - Logging out asks first. =GET /logout= is the confirmation page the rail's signout square and the More menu link to; the button on it posts to the same path, which is still what ends the session. - Bookmarks and Snippets are gone from the rail and the More menu. Both land on the profile, where they are tabs, and every square left in the rail is an instance-level destination. - A repository tree marks a directory with a folder glyph and a file with a file glyph. The trailing slash still says it in text and the glyphs are =aria-hidden=, so the cue no longer rests on the link colour alone. Per-filetype icons are deliberately not done. - A =/search= hit carries its kind in a chip, in the words =search= prints, so the meta line no longer has to say "repository". - A settings row lines its control up on the right: a checkbox, a number and a full-width text input all end on the same rule, with Save in its own column. The label column keeps a 14rem floor so a hint does not wrap a word to a line. - The email rows on =/settings= put their buttons and confirm field on the right, so they line up however long an address is. - =/admin/users= draws a disabled Promote on an account that is not active, rather than leaving the cell short, and the page is wide. At 48rem the Actions cell clipped the second form's button, which left its confirm field looking like it belonged to the button before it — an active row read as though Promote wanted the name typed. The cell is now two fixed slots, each right-aligned, so the role button, the confirm field and the access button each hold one column whichever controls a row carries: an admin's row has a field on both forms, a disabled account's enable form has none. The field is 14rem, which fits "type to confirm" for a name of ordinary length. - The profile's inactive tabs draw their underline in =--line=, so the strip reads as a track the current tab is marked in. - =.listhead= sheds its children's block margins, which is what kept a =p.meta= from sitting level with the heading beside it. - =nav.tabs= has a bottom margin. - The footer links the iOS client. The e2e suite runs in parallel (#246). - =startInstance= linked a byte-identical 35MB gitbayd for each of the 207 tests, about 0.8s apiece: go's build cache covers the compile, not the link. Each binary is now built once per process, lazily, so a =-run= of one test links only what it needs. - 202 of the 207 tests take =t.Parallel=; the five calling =t.Setenv= stay serial. =freePorts= allocates from an atomic counter instead of binding =:0= and closing it, which raced once tests overlapped — =waitForPort= only asks whether something is listening, so the loser talked to another test's server. - Locally on 12 cores the suite goes from 637s to two or three minutes. The =-timeout= floor the harness enforced (#143) is gone with the reason for it; =make test= and =.gitbay/ci.yml= still pass their own as a ceiling for a real hang. * v1.33.0 — 2026-09-21 Five findings from the outside review of the web UI (#241–#245). - A run row on the builds page led with a ten-character sha, so recognising a build meant opening the commit or already knowing the sha (#241). =build list= resolves each distinct commit's subject in one =git log= and returns it as =subject= on every build, so the CLI, the API and the web all name the commit. The row leads with the subject and the sha follows as metadata; a build whose commit is gone falls back to the sha. - =build list= takes =--limit = and =--cursor =, the keyset cursor =issue list=, =mr list=, =repo list= and =feed= already have (#244). Without the flags it returns the newest fifty matching builds as before, so existing scripts are unchanged — the cursor is what reaches past that window. The page asks for thirty, offers "older" carrying every filter, and says its count is this page's when there is another. Before, filtering to a sparse status reached much further back within the same cap and appeared to raise the run count. - The profile is sections rather than one stack (#242). =/{owner}= is the repository list; About, Activity and an organization's People are tabs beside it at =/{owner}/-/about=, =/-/activity= and =/-/people=. A tab with nothing on it is not offered and its URL is a 404. The About text and the year of activity squares used to sit above the repository list and push it below the fold. =profile show= is unchanged. - Explore names its filter: the number of repositories, the active query and a link that clears it, the line the builds page already carried (#243). An empty filtered list says nothing matched rather than that the instance has no public repositories. - The landing page's picture was !315, closed without merging, with an empty review panel and alt text promising reviewers that were not there (#245). It is !450: merged, two CI checks reporting success with how long each ran, and an approval. The picture is a figure with a caption saying what it shows. * v1.32.1 — 2026-09-20 A push alert names the account it is for (#89). - The payload carried =path= and nothing else, so a device signed in to more than one account could not tell which instance a notice came from: two instances can hold the same =owner/name=. It now carries =instance=, the instance's =site_url=, and =user=, the recipient's username — together the account's identity. - =DuePush= joins =users= for the recipient; no schema change. - =notifications device add= now returns the row id, so a client that wants to deregister does not have to list devices and match its own token against the truncated display value. - The alert carries =aps.badge=, the recipient's unread inbox count. =DuePush= counts it at send time, a subquery against =inbox=, so a cleared inbox is reflected even though the row was queued earlier. * v1.32.0 — 2026-09-20 Push notifications to iOS devices (#89): activity reaches a registered phone the way it already reaches the inbox and mail. - gitbayd talks to APNs directly over HTTP/2, authenticated by an ES256 JWT signed with an operator-supplied =.p8= provider key. New config section =[push]=: =enabled=, =key_file=, =key_id=, =team_id=, =topic= (the app's bundle identifier) and =environment= (=production= | =sandbox=), all validated at load when enabled — a misconfigured =[push]= refuses to start rather than filling a queue nobody is watching. An APNs key belongs to a bundle id: a self-hoster ships their own build under their own =topic= to use this; the App Store build talks to gitbay.org. - Migration 0059: =push_devices= and =push_queue=, and =users.notify_push= (default on). - =notifications device add= (token on stdin), =device list= (token shown truncated), =device remove =, and =notifications settings push on|off=. A device is dropped automatically when Apple reports the token dead (410 Unregistered or BadDeviceToken). With =[push] enabled = false= nothing is queued and =device add= refuses, rather than registering a device nothing can deliver to. - =[retention] push= caps the delivery queue, beside =mail=. - =dashboard= reports the push queue for admins beside the other five, by device id: a =key_id= or =team_id= Apple did not issue passes config validation and then dead-letters every send. - =issue assign= now files a notice, so assignment reaches the inbox, mail and push. - Notification text is sent in full, private repositories included: a repository's name and item number reach Apple and can appear on a lock screen. - Web: the notification settings page carries the push toggle and the device list, with removal behind confirmation. No web page offers a device-add form — only the app can mint an APNs token. * v1.31.1 — 2026-09-20 The stylesheet URL carries the build's hash (#239). - =/static/style.css= was served with a day's cache lifetime at an unversioned URL, and =must-revalidate= only forces a check once that lifetime expires. For a day after a deploy a browser rendered the new markup against its old stylesheet; the ETag was never consulted. - The layout stamps the served bytes' hash on the link. A deploy changes the URL, so a cached copy cannot answer it, and a request carrying the current hash names bytes that cannot change and is served without revalidating at all. * v1.31.0 — 2026-09-20 The desktop layout (#226): the web UI uses a wide screen. - One centered container at 100rem; the repository header, main and footer align on it. Text keeps its measure. - The repository header is two rows: name, description and buttons, then the tabs. - Issue, merge request, build, explore, search and notification rows are one line above 64rem, and those pages render at the container width. - The dashboard is three columns: pinned repositories with open issue, merge request and last-build counts; a tile per queue with the queue rows below; the activity feed. - A file navigator beside blob, blame and edit pages lists the file's directory and marks the file. - Side columns: state, labels and open milestones on the issue and merge request lists; status, jobs and branches on builds; topics on explore; kinds on search; sections on repository settings, account settings and admin. A label or milestone nothing is filed under is left out, so the merge request list offers the labels that have merge requests rather than every label the repository holds; the branch group caps at ten, and the branch field below it still takes any ref. - Below 62rem the facet and section columns become a closed disclosure before their content, headed by the filters in force, and the navigator hides, since the tree page is the navigator on a phone. Below 80rem the dashboard's pinned repositories return to a chip row above the queues. Three accessibility defects the wider scan found, on pages the sixteen-page one never covered (#226): - A label chip carries a tone per scheme. Chip text is 12px, which owes 4.5:1 against the ground mixed from the colour itself, and no single colour clears that on both canvases; the stored hex still names the label, and the page picks the tone. A page stamped light or dark now sets =color-scheme= to match, so native controls follow the theme. - Every =pre= scrolls sideways, so every one takes =tabindex="0"=: a keyboard reaches a blame view, a build log and a search match the way it already reached the pre blocks of rendered markup. - A link that shares its line with other text is underlined — a path crumb, a heading that names an owner, an empty-state note. Link and muted text are 1.07:1 apart in dark. The profile about text is a file (#236). *Upgrade note.* Run =gitbayd admin migrate-profile-about= after upgrading. Migration 0058 parks each owner's about text in a holding table and drops the columns; that command writes it into a public =/.gitbay=. Until it runs, profiles that had an about show none. Nothing is lost in between — the table keeps the text. - The about text on a profile is =profile/README.md= or =profile/README.org= on the default branch of =/.gitbay=, read with that repository's own access. The extension picks the renderer; =.md=, =.org= and =.markdown= resolve in that order. =profile show= gains =about_path=, the file the text came from. - =profile set= and =org profile= lose =--about=, =--about-format= and =--file=. The about is written by a push or =repo commit-file=, the way a wiki page is. The settings page points at the file and offers to create the repository that holds it. - Repository names may start with a dot; owner names may not. A repository whose name starts with a dot stays out of =explore= and off the profile's repository list, and remains in =repo list= and at its own URL. - =repo commit-file= writes the first commit of a repository that has never been pushed to. An unresolvable branch is still an error anywhere the repository already has refs. A web UI sweep. - The rail's squares are 44px at every width, and its glyphs are 20px on a phone. Below 34rem eleven squares no longer fit, so explore, bookmarks, snippets, new repository, settings, admin and log out move into a More menu and the strip keeps dashboard, search, notifications, More and your account. - Snippets are on the rail. The link on a profile page stays. - =/new= creates an organization as well as a repository; the form leaves the profile page. - =repo fork= takes =--owner=, and the web's Fork button opens a form with an owner and a name. The owner is you or an organization you administer, checked as =repo create= checks it; an organization's fork is not counted against your repository quota. A name of your own is how you fork the same repository twice. - The dashboard's pinned key reads above the list rather than under it, its build dot sits on the row's centre line, and a pinned chip on the second row has its bottom border back. - The activity feed drops its state dot. - The SSH key and account tables keep each row on one line and scroll sideways, rather than wrapping a fingerprint or a timestamp. - =.pathbar= actions sit on the centre line with the ref and the crumbs. The SonarCloud dashboard is at zero (#238). Four of its 38 findings were real; the rest are dismissed with their reasons recorded on the dashboard. - A trailing-slash redirect cannot leave the site. =net/http= fills =URL.Scheme= and =URL.Host= from an absolute-form request line, which RFC 7230 requires a server to accept, so =GET http://evil.example/cmc/= answered =301 Location: http://evil.example/cmc=. A browser sends absolute form only to a proxy, so nothing reached this through one directly. - The global search, repository search and register forms no longer carry =autofocus=, which moved focus past the heading, the result count and the search page's filters. The diff line-comment box keeps it: that form renders only after the reader asks for it on a line, and with no JavaScript nothing else carries focus across the reload. * v1.30.0 — 2026-09-19 Every command runs on every surface, markup previews before it is written, and the web findings from the forge comparison (#232, #233, #234, #235). *Upgrade note.* Dropping =SSHOnly= widens what a full-scope API token can do: it can now administer the instance and mint further tokens. Review the tokens you have issued and re-mint as =--scope read= any that only need to read. - *Every command now runs on every surface.* The registry's =SSHOnly= flag is gone, and with it the refusal that met build secrets, mirror credentials, API token minting, web sessions, deploy keys, the audit log and instance administration when they arrived over HTTP. What a caller may do is the account's rights narrowed by the scope of the key or token the request came with, decided in one place. A full-scope API token can now do anything its owner can, minting another token included; a read-scoped one still cannot write. Operators should review the tokens they have issued and re-mint as =--scope read= any that only need to read (#234). - =/admin/users= lists every account with the state filter and cursor =admin user list= takes, and runs promote, demote, disable and enable per row. Demote and disable ask for the username to be typed (#234). - =[registration] notify_admin= mails the instance's admins when an account becomes active: an invite redeemed, or an open-mode signup that verified its address. Off by default, requires =[mail]=, and queued like any other notice (#234). - Every web form that takes markup has a Preview button beside its own submit: issue and merge request create, their edit and comment boxes, release create and edit, the profile about text, and the file editor on a path the forge renders. It renders the draft above the textarea and writes nothing (#235). - Links inside running text are underlined: the meta lines on issues, merge requests and releases no longer tell a link apart by colour alone. - File view line numbers are 24px targets and keep the focus ring; chroma's LineLink rule, which set =outline: none=, is dropped from the served palette. - Every rendered =
= and the landing page's quickstart block take
  keyboard focus, so a block that scrolls sideways can be reached.
- On one column the issue and merge request aside follows the thread
  instead of preceding it.
- The file tree at 320px keeps its last column inside the card.
- A path no route matches renders the 404 page instead of a plain-text
  body.
- An org image link with no description gets its file name as alt text,
  so a README badge is a named link.
- Release titles are h2.
- Text responses are gzipped for clients that accept it.
- =web theme set system|light|dark= and an Appearance section on the
  account page fix the colour scheme per account. Migration 0057 adds
  =users.theme=.
- A path that only misses because of a trailing slash redirects to the
  path without it: =/cmc/= reaches =/cmc=, =/cmc/-/snippets/= reaches
  =/cmc/-/snippets= (#233).

* v1.29.0 — 2026-09-19

The icon nav is a top bar at every width.

- The 48px row of glyphs that narrow screens already used is now the
  only shell layout; the left column is gone. The row keeps the same
  order, labels and current-page mark, with settings, admin and the
  account cell at the far right.
- The landing picture is recaptured under the new shell.

* v1.28.1 — 2026-09-19

The newcomer route names its web path (#226).

- After registering, the page and the verification mail say the code
  can be pasted under Settings after signing in with an emailed link,
  beside the terminal command.
- The SSH keys wiki page names this instance instead of a placeholder;
  the Quickstart leads with the browser route and Homebrew before
  =go install=.

* v1.28.0 — 2026-09-19

Labels on merge requests (#231), and a one-column dashboard. Migration
0056 adds =mr_labels=; the daemon migrates on start.

- =mr label   --add  --remove = attaches and
  detaches labels the way =issue label= does, resolving an org's label
  first and creating a repository label on the fly; =mr list --label=
  filters; =mr show= carries them; the event is =mr.labeled=.
- The merge request page has a Labels group with the same form as an
  issue; list rows show label chips and =?label== filters the list.
- =label list= and the labels pages count merge requests beside issues;
  removing a label detaches it from every issue and merge request. An
  org label folding repository rows moves merge request rows too.
- The dashboard is one column: the activity feed is a section after
  the queues, since the icon rail leaves no wide column to balance.

* v1.27.0 — 2026-09-19

The follow-ups from the forge benchmark (#226) and two profile fixes.

- On a wide repository overview the About block (counts, licence, latest
  release, build, contributors, languages) and the clone commands sit in
  a column beside the file table; under 62rem the order is unchanged
  (#228).
- The landing page's Explore and Create an account routes sit above the
  picture, and the picture is capped so the first screen carries a call
  to action (#229).
- Each row of the merge request list shows the combined check state of
  its head, linking to the diff, and a comment count (#230). Labels on
  merge requests do not exist yet (#231).
- The profile page lists repositories after the activity graph, and a
  table in rendered prose keeps its last row's bottom border (#225).

* v1.26.0 — 2026-09-18

A closed merge request can name the request that carried its change
(#223). Migration 0055 adds the column; the daemon migrates on start.

- =mr close   --by = records, at close time, that !m
  supersedes !n; =mr edit  --superseded-by = sets it on a request
  already closed and =--superseded-by none= clears it. Both refuse a
  number that is not a request in the same repository, or the request
  itself. =mr show= carries the field.
- The web close form takes the number as an optional field. The closed
  request's title line says "in favour of !m"; the request it names
  says "supersedes !n". The state stays closed; nothing is inferred
  from prose.
- On a phone the dashboard's activity feed stacks after the queues; the
  aside-first rule is for checks and reviewers on a merge request
  (#222).

* v1.25.0 — 2026-09-18

The site rail is an icon column (#220).

- At desktop the rail is a 3.5rem column of inline glyphs: dashboard,
  explore, search, notifications with the unread count as a badge,
  bookmarks, new repository, then settings and admin, then the account
  and sign-out. The search field leaves the rail; =/search= has its own.
- Under 52rem the rail is one 48px row that neither wraps nor scrolls.
- Every icon link carries an accessible name (=aria-label=, a title and
  visually hidden text) and hides its SVG from assistive tech; a test
  enforces it.
- Pinned repositories are a chip row under the dashboard heading; the
  review queue is the dashboard's section. Neither is in the rail.

* v1.24.1 — 2026-09-18

The dashboard feed and the phone-width rail strip (#222, #220).

- Build events record their commit; the dashboard's activity feed folds
  a commit's jobs into one line per run with the combined state and a
  state dot. A status outside the known set is never shown as success.
- Feed times are relative, with the exact UTC time in a title, as the
  tree listings already do.
- Under 52rem the rail strip keeps the brand on its first row, shows
  pinned repositories as a compact labelled sub-row, and New repository
  is a =+= button at every width.

* v1.24.0 — 2026-09-18

The web follow-ups to the design foundation (#219, #220, #221, #222,
#224, #225), and a repository maintenance command.

- =build list= takes =--ref=, =--status= and =--job=, pushed down to the
  store query; the builds page reads the same three as GET parameters
  with shareable links, and groups a commit's jobs into one run showing
  the combined state (#224).
- The merge request page's "Files changed" view is wide; the
  conversation and commits views stay at reading width (#219).
- The profile page lists repositories above the biography; the
  contribution grid carries month labels and a legend (#225).
- The dashboard shows populated queues first, collapses empty ones to a
  heading, and says what the two Open lists cover (#222, in part).
- Under 52rem the rail strip keeps one scrolling row and shows pinned
  repositories as a second row instead of hiding them (#220).
- Polish from the v1.23.0 review: caption spans instead of dangling
  labels, runner attach keeps a rejected key and reports success, the
  tree root of the default branch is the repository home so the header
  is identical within the code tab, the file editor is wide (#221).
- =admin mr prune  ... --yes= drops the named merged or
  closed MRs' =refs/merge-requests/N/head= and runs =git gc --prune=now=
  on the repository, for commits a history rewrite left reachable only
  through them. Open and source-gone MRs are refused. Nothing drops a
  head ref on its own. =mr diff= and the MR page say when a head is gone
  (#227).

* v1.23.0 — 2026-09-17

The web design foundation (#218). Tokens measured in both schemes, one
control family, content widths per page, and the landing, repository
overview, merge request and settings pages recomposed. Set =[web]
title= to =gitbay= in lower case; the templates already are.

- =style.css= rewritten on tokens defined on =:root= and again for dark;
  =TestTokenContrast= enforces the floors and
  =TestEveryTemplateClassHasARule= that every template class has a rule.
- Buttons are primary, secondary, quiet or destructive; one focus ring on
  everything; success and error flashes share one shape.
- Every page picks a width: wide for code, reading for lists and threads,
  bounded for forms.
- The repository header shows description and topics on the code tab
  only; the overview leads with the file table and puts clone commands
  and facts after the README; "Find file" is "Search code".
- A merge request states who merged or closed it under its title and
  explains an empty diff.
- Repository settings are bounded rows with a consequence beside each
  control, a saved flash, a rejected value kept, and topics as one field.
- Landing copy says what the product is and where to go; the register
  form says to paste the contents of the key file and links the SSH keys
  wiki page.
- The landing page shows the merge request page, light and dark,
  captured from gitbay.org (two embedded PNGs under =/static/img/=).

* v1.22.1 — 2026-09-13

One merge gate fix.

- =require-checks= no longer refuses a merge request head that carries
  no statuses when nothing was going to report one: a repository with
  no =.gitbay/ci.yml=, or one whose jobs all wait on a schedule or a
  tag, had no mergeable merge request and no remedy but turning the
  setting off. A head whose config carries a job a push runs, and a
  head in a repository that has recorded a status before — which is
  what reporting from outside through =status set= looks like — is
  still refused (#216).

* v1.22.0 — 2026-09-12

The CLI output sweep (#183). The rules are on the wiki's Users page,
under "Output rules".

- A bad invocation prints the command's registered usage, the one
  source of it; the hand-written copies are gone.
- The =gitbay= CLI pads list rows into aligned columns at a terminal
  and leaves the tabs alone when piped.
- An empty list prints nothing on stdout and =nothing to list= on
  stderr.
- Every refusal (exit 4) says what to do instead.
- =status list= and =repo topics= print rows.
- =admin runners remove=; =forget= stays as an alias until the next
  release.
- A missing positional argument prints the hint and the usage line
  (#215).

* v1.21.0 — 2026-09-12

The web UI/UX sweep (#182).

- Destructive controls ask for the object's name typed beside the
  button: release delete, snippet delete and file remove, team delete,
  label remove, and SSH key, email and PGP key removal. Reversible
  controls keep a plain button.
- Login returns to the page that asked for it, and says so.
- One timestamp format everywhere, =2006-01-02 15:04 UTC=.
- Tags on the refs page and in the release form are in version order,
  newest first.
- The file editor explains up front when signed commits or
  merge-requests-only protection would refuse the commit, answers 404
  for a branch that does not exist, and says when a path is new.
- Merge refusals name the strategy rather than the flag; an issue
  closed by a commit reads "closed by  in commit ".
- Repository settings: every Save names its field. Labels: the colour
  column appears only when it means something. Sidebars use one shape
  for empty. List rows show the state only under "all" and say "in
  ". Global search counts its results. A merged MR shows
  its merged head and a deleted source branch. The repository home
  shows the SSH clone URL beside HTTPS. The settings page names
  =auth token create=.

* v1.20.1 — 2026-09-11

One config check.

- A negative =limits.max_snippets_per_user= is refused at config load,
  as =max_repos_per_user= and =max_bytes_per_user= already were; it
  used to pass validation and read as unlimited (#214).

* v1.20.0 — 2026-09-11

Snippets (#195): named text files a user owns outside any repository,
shared by URL and edited in place.

- Migration 0054: =snippets= and =snippet_files=.
- =snippet create|show|list|edit|delete= and =snippet file
  set|get|remove=. Files are UTF-8 under =limits.max_snippet_bytes=
  (1MB), at most 64 per snippet; a snippet keeps at least one.
  =limits.max_snippets_per_user= (0, unlimited) caps how many an
  account may own, admins included. Visibility =public=, =unlisted=
  (default) or =private=; a private snippet is not found to everyone
  but its owner and admins.
- Web: =//-/snippets= lists, each snippet page renders its
  files with a raw route per file, and the owner creates, edits and
  deletes from the page through the same commands. The owner page
  links to the list.

* v1.19.0 — 2026-09-11

Labels and milestones an org defines once for every repository under
it, and =Closes owner/name#N= acting across repositories (#203).

** Org labels and milestones

- Migration 0052: =labels= and =milestones= rows belong to a
  repository or to an org, exactly one, with a partial unique index
  per scope. Existing rows and their memberships keep their ids.
- A repository owned by an org sees the org's labels and milestones
  beside its own; =issue label --add=, =issue milestone= and =mr
  milestone= resolve the org's row first. A repository cannot create,
  recolour, remove, close or reopen a name its org holds; the refusal
  names the org command.
- =org label set|list|remove= and =org milestone
  create|list|close|reopen=, for org admins. Creating a name that
  repositories under the org already use folds them in: their issues
  and merge requests move to the org's row and the repository rows
  go. =repo transfer= into an org folds the same way. Counts on org
  rows span the repositories the caller can read.
- Web: read-only =//-/labels= and =//-/milestones=, linked
  from the org page; an "org" mark on repository label and milestone
  pages, whose forms act on repository rows only.
- =label list= and =milestone list= JSON carry =org: true= on an org
  row.

** Cross-repository closes

- =Closes owner/name#N= in a commit on the default branch, or in a
  merged merge request's title or body, closes that issue when the
  actor holds write on the target and the pushing key's scope allows
  it: a deploy key never acts outside its binding. An unknown or
  refused target does nothing and logs nothing; the text stays a
  plain autolink. An archived target is refused. Bare =owner/name#N=
  without a keyword stays display-only.

** Store

- A migration whose first line is =-- foreign_keys: off= runs on one
  pinned connection with foreign keys off, re-enabled afterwards and
  =foreign_key_check= required empty. Rebuilding a table other tables
  reference loses the children's rows otherwise, even under
  =legacy_alter_table=.

** Behaviour changes

- =milestone create= exits 1 on a duplicate title, as =org milestone
  create= does; it exited 2 before.

Upgrade: replace the binary and restart; migration 0052 runs on
start and copies every label and milestone row once.

* v1.18.1 — 2026-09-11

A runner that cannot hang on a dead connection, SSH keys with names,
and dependency updates.

** CI runner

- Every ssh invocation carries =ConnectTimeout=10=,
  =ServerAliveInterval=15= and =ServerAliveCountMax=3=, placed after
  the identity and the operator's =-ssh-opts= so an operator's value
  wins. A claim whose TCP session had died under it blocked a laptop
  runner's poll loop for thirteen hours; it now fails within a minute
  and the loop resumes.
- The build comment in =deploy/Containerfile.ci= stages the file in
  the runner user's home before =podman build=: a login shell under
  =su= cannot read root's stdin and does not share root's =/tmp=.

** Keys

- SSH keys carry a label (migration 0051). =keys add --label= defaults
  to the authorized_keys comment; =keys label  []=
  renames or clears it. =keys list=, =admin user show=, =repo
  deploy-key list= and the settings page show it. Deploy, runner and
  admin-created keys take the comment as their label. Closes #208.

** Web

- Org autolinks stop before trailing punctuation: a bare URL closing a
  sentence or a parenthesis no longer takes the =.=, =,= or =)= into
  the href. Closes #209.

** Dependencies

- golang.org/x/crypto 0.57, x/image 0.46, x/net 0.59, x/term 0.46.
  Closes #205.

* v1.18.0 — 2026-09-10

What the first real job on a user's runner needed. A twice-daily
archive pull that had run on the server's host moved into a container
and then onto a laptop, and each hop found a gap.

** CI runner

- A secret with newlines — a private key — reaches the build. An env
  file has no escape for one, so such values are named on podman's
  command line with =--env NAME= and valued in the podman process's own
  environment; the value touches neither argv nor the file.
- =GITBAY_SSH= in every build's environment: the instance's ssh
  destination as the build reaches it. Under podman the host's own
  addresses belong to the container (pasta), so a loopback remote is
  rewritten to the address pasta exposes the host at. A job that
  publishes back to the instance uses the variable rather than
  guessing. Inside a container =ssh= expands =~= from the passwd entry,
  not =$HOME=; keep a build's ssh config in the workspace and pass =-F=.
- =-identity= drops the user's =~/.ssh/config= (=-F /dev/null=):
  =IdentitiesOnly= kept identities the config named, so a laptop runner
  authenticated as the user's own full-scope key and, on an admin's
  machine, could claim every repository's builds. A first-seen host key
  is accepted, since a service cannot answer a prompt.
- A schedule tick queues nothing while the job's last build is pending
  or running, matching the push path. A repository no runner served
  gained one row per tick forever. A finished build does not suppress
  the tick; a schedule re-runs an unchanged commit on purpose. #206
- =admin runners forget = drops a key's heartbeat row; the
  key stays. =admin runners= rows are the repositories a key may claim.
- The bay1 unit names no =-repos=: the runner key's attachments are the
  boundary. =gitbay-ci:2= carries =python3-venv= and =sqlite3=, since
  it is also the default image for every repository the runner is
  attached to.

** Decisions

- Claim order stays oldest-first across everything a key may claim; a
  repository that must never wait on another gets a runner of its own.
  #207
- No per-account cap on queued builds and no schedule floor: with the
  tick dedupe a repository with no runner holds one row per scheduled
  job, and a busy schedule spends its owner's compute. #206
- Attaching a runner is =n/a= on iOS: the key lives on the machine that
  runs builds.

** Documentation

- Users: =GITBAY_SSH=, ssh's home inside a container, the =ci.yml= caps
  (ten jobs, fifty steps, 4096 bytes a step, 64 KiB a file) and where a
  broken config shows up. Admin, CI and Threat-Model follow the changes
  above.

** Upgrading

Replace the binaries and reinstall the CLI. No migration. A runner
started with =-identity= (every runner =gitbay-runner init= set up) now
ignores =~/.ssh/config=; a runner that relied on it for a =ProxyJump=
or an alias passes the equivalent in =-ssh-opts=. Homebrew:
=brew upgrade krz/tap/gitbay-runner=.

* v1.17.0 — 2026-09-09

CI without the operator's compute: a runner anyone installs and
attaches to their own repositories, and the server rule that makes
that safe. The rest of the #184 list besides.

** CI

- A runner key claims builds only for the repositories it is attached
  to. =repo runner add  < key.pub= attaches one; a key the
  server has not seen lands on the caller's account with scope
  =runner=. =repo runner list= shows each attached key with its last
  poll and the build it holds; =repo runner remove =
  detaches it. All three on the settings page. A runner-scoped key
  with nothing attached claims nothing, whoever owns it; a full-scope
  admin key still claims any repository. =runner log= and =runner
  done= refuse a build outside the key's attachments. #184
- Closed: any account could add a runner-scoped key, and =runner next=
  with no arguments handed it the oldest pending build on the
  instance, secrets included.
- =runner next --untrusted=: without it a merge request head from a
  fork is never claimed. The runner passes it when started with
  =-untrusted=; the bay1 unit does, because it isolates in podman.
- =gitbay-runner init= generates a key under =~/.config/gitbay-runner/=,
  writes =config.toml= beside it, and prints the public key with the
  command that attaches it. The runner reads that file when present
  and flags override it. =-identity=, defaulting to the generated key,
  makes ssh and git use the runner's own key and no other. Homebrew:
  =brew install krz/tap/gitbay-runner=, then =brew services start
  krz/tap/gitbay-runner=. Releases ship =gitbay-runner= binaries.
- =admin runners= heads its output with the queue: builds pending now,
  and over the last day the builds claimed, the wait to claim (average
  and worst) and the builds the reaper ended instead of a runner
  reporting them. Rows are per key, with the fingerprint and the
  repositories the key may claim. Migration 0049. #184
- Stop tests for the runner's drain: a signal mid-step, the drop-in's
  =KillMode= and =TimeoutStopSec=, and a transient systemd user unit
  where one exists. #179
- The CI wiki page carries every push shape against every job kind and
  what dedupe, path filters, schedules and the reaper make of it, with
  a test per row. #176, #177

** Importing

- =import-issues --api-base= reads Forgejo instances: paging by =limit=,
  oldest first, comments read once, attribution and pull heads taken
  from the API base rather than github.com. #191

** Documentation

- Users: "Your own runner". Admin, Threat-Model, CI, FAQ and Parity
  follow the attachment rule.

** Upgrading

Replace the binaries and reinstall the CLI. Migration 0050 (runner
attachments; the runner heartbeat table is rekeyed by key and
emptied), applied on start. One behaviour change: a runner polling
with a key of scope =runner= claims nothing until a repository admin
attaches it with =repo runner add=. Do that right after the restart.
A full-scope admin key is unaffected. Merge request heads from forks
are built only by a runner started with =-untrusted=.

* v1.16.0 — 2026-09-08

What stops a stranger from moving here: the repository plumbing and
the team gates the #185 walk found missing, twelve merge requests
merged as one stack.

** Repositories

- =repo settings default-branch  = moves HEAD and
  the record; a branch that does not exist is refused. A first push of
  =master= or =trunk= into a fresh repository moves the unborn HEAD to
  the branch that arrived, so the clone checks something out. Settings
  page select. #189
- =repo rename  =: the row and the directory move
  together, with transfer's revert on a failed move. Clone URLs change.
  #190
- =repo settings require-mr on=: an existing protected branch refuses
  every direct push in pre-receive, =repo commit-file= and the web
  editor included; =mr merge= is its only writer. Creating the branch
  is still a push. #197
- =repo settings protect-tag = (and =unprotect-tag=): matching
  tags are created once and refuse moves and deletion. Separately, a
  tag a release is anchored to refuses both while the release exists,
  protected or not; delete the release first. #201
- =repo access list= reports effective access: one row per account
  with the highest role and where it comes from (owner, direct, org
  admin, org member, team). It used to list direct grants only, so a
  repository reached through teams answered with nothing. #200
- Removing an org member drops their team memberships in the same
  transaction. They kept every grant their teams carried, and re-adding
  them to the org restored nothing because nothing had gone. #196
- An outsider gets one answer about an org: its existence and members
  are public, its teams are for members, and =org team list= and =show=
  refuse a non-member rather than say the org does not exist. Ref #200
- A repository name may not end in =.atom=, as it may not end in
  =.git=. Ref #192

** Merge requests

- A push that leaves the merge request's diff unchanged keeps its fresh
  reviews: the patch-id of each revision against its own merge base is
  compared, and a rebase onto a target that moved on no longer stales
  every approval. A push that changes the diff stales them as before.
  #198
- The merge gates are one computation shared by =mr show= (a =gates=
  block: approvals counted and required, owners outstanding per file,
  open threads, checks, whether a fast-forward is possible), the merge
  request page, and =mr merge=, which names every unmet gate in one
  refusal rather than the first. =mr review= reports =counts= and says
  when a verdict is advisory because the reviewer cannot write. #199

** Notifications

- =@name= in an issue, merge request, comment or diff comment files
  "mentioned you" in that account's inbox and makes them a participant
  of the thread, provided they can read the repository and have not
  muted it. Watchers are not told about a mention addressed to someone
  else. Migration 0047. #202
- =notifications settings mail on|off= (and =show=): inbox rows are
  filed either way, this is the mail half. Login links are not activity
  and still arrive. The account page carries the same switch.
  Migration 0048. Ref #194

** Web

- Atom feeds: =/{owner}/{repo}/releases.atom=,
  =/{owner}/{repo}/log.atom[/{ref}]= and =/{owner}/activity.atom=,
  rendered over the rows the pages already serve. The owner feed covers
  public repositories only, since a reader carries no session; a
  private repository answers 404. The releases, log and owner pages
  carry the discovery link. #192

** Documentation

- The Users page says what differs over stock ssh: a multi-word value
  needs a second layer of quotes, and =help = replaces =--help=.
  The companion-wiki paragraph #170 made stale now points at
  =.gitbay/wiki/=. #204

** Upgrading

Replace the binary and reinstall the CLI. Two migrations (0047, the
mentions table; 0048, the per-account mail switch), applied on start.
Nothing changes behaviour until a setting is turned on: =require-mr=,
=protect-tag= and =mail off= are all opt-in.

* v1.15.0 — 2026-09-07

Builds run in containers, a rebase is not rebuilt, and a runner restart
no longer strands the build it was running.

** CI

- A build's steps run in a rootless podman container, one per job. The
  runner clones outside the container with its key and bind-mounts the
  workspace in, so a step cannot reach the key, the runner's environment,
  or another build's workspace. =image:= is required per job and is
  validated as a reference, never pulled: an operator provisions images
  on the host. A runner configured for podman that has none refuses to
  start rather than fall back to the host. =-isolation none= is the
  explicit host-execution mode. =-cpus= and =-memory= cap a build's
  container. #144
- Builds have a home directory that outlives the build, so the Go module
  cache and the sonar scanner survive between runs. It is mounted into
  the container at the same path, and only that directory. Ref #144
- The host preparation script, the unit drop-in and the weekly image
  prune ship with =make deploy-runner=; each hardening flag the drop-in
  relaxes carries the reason in place. Ref #144
- A nightly canary on the runner host proves the boundary holds:
  =cmc/ci-smoke= runs a job that tries to read the key and list sibling
  workspaces, and fails if either succeeds. Ref #144
- Dedupe keys on the commit's tree, not its sha. A rebase that changes
  nothing is not rebuilt: the new commit gets the earlier success as its
  status, naming the build. Scheduled and tag builds carry no tree and
  are never reused. #177
- =vuln= and =sonar= run nightly on =main= rather than on every push;
  neither could inform a merge. Branches run =build= and =test=. #177
- A rewritten branch is filtered against the merge base rather than the
  old tip, so a rebased branch whose own commits change code no longer
  reads as a docs-only push and skips its jobs. #176
- A build whose runner's log stream ended with no outcome is failed two
  minutes later, instead of sitting =running= until a fixed deadline.
  Migration 0046. Ref #179
- The runner retries reporting a finished build's outcome, four times
  over about thirty seconds, when the server is unreachable. A gitbayd
  restart at that moment used to lose the result. Ref #179
- The runner drains on SIGTERM: it claims nothing more, finishes and
  reports the build in flight, then exits. A second signal exits at once.
  The unit allows fifty minutes. #179

** Repositories

- =repo show= reports the caller's watch and bookmark state, and the
  parent of a fork when the caller can read it. #178
- =repo unwatch= clears the row from either state; =repo mute= is the
  explicit silence. Watch then unwatch used to leave an account below
  the default with no way back. The web header toggle follows. #180

** Upgrading

Replace the binary and reinstall the CLI. One migration (0046, the
build log's close time), applied on start.

The runner now defaults to =-isolation podman= and refuses to start
without a working podman and a named image. Prepare the host with
=deploy/runner-podman-setup.sh= and build =localhost/gitbay-ci:1= from
=deploy/Containerfile.ci= before deploying it, or pass =-isolation
none= to keep running steps on the host. Validate on a scratch
repository with =-repos= scoped to it first; the wiki's Admin page has
the procedure.

The runner's unit gains =TimeoutStopSec=50min= and =KillMode=mixed= so
a stop reaches the runner alone and it can drain; under the default
kill mode systemd ends the build's container with the runner.
=make deploy-runner= therefore waits for a build in flight.

* v1.14.0 — 2026-09-06

Logging into the web without an SSH key, wikis inside the repository,
CI that skips what a change does not touch, and the browser catching up
with the command line on nine capabilities.

** Accounts and the web

- A browser session can be requested by mail: the login page takes a
  username or a verified address and sends a link that works once and
  expires in fifteen minutes. An account with no SSH key had no way into
  the web at all. The response never says whether the account exists, and
  the form appears only when the instance has SMTP. #155
- A login link resolves to any verified address on the account, not only
  the primary. #158
- Login mail is queued rather than sent from a goroutine, so a link in
  flight survives a restart. #159
- A suspended account cannot mail itself a session, and a link minted
  before suspension opens nothing. #156
- The session cookie is =SameSite=Lax=, and a test now walks every
  mutating route to assert it carries the origin check. #157
- =POST /register= is reachable on an open instance again.

** Wikis

- A wiki lives at =.gitbay/wiki/= on the default branch, so editing one
  is editing a file in the repository — a push, or the web editor — and
  it is cloned, diffed and reviewed like anything else. The companion
  =.wiki= is gone. #170

** CI

- Path filters: =paths= and =paths-ignore= per job, so a docs commit
  does not run the whole suite. #169
- A branch's first push derives its diff base from the merge base
  rather than treating every file as changed. #171
- A job filtered out records a skipped status instead of nothing, so
  =require_checks= cannot wait forever for a check that will never
  arrive. #172
- A force-push no longer leaves queued builds pointing at an object
  that is gone; the orphan is cancelled when a runner claims it. #152
- A build step's environment is constructed rather than inherited. It
  was =os.Environ()= plus the build's variables, which handed repository
  content everything set on the runner service. A step now gets =PATH=,
  =HOME=, =LANG=, =CI=, its =GITBAY_*= variables and its secrets. =HOME=
  is the workspace, so a build cannot read the runner's =.netrc=,
  =.npmrc= or =.gitconfig=, where tools keep credentials. Ref #144
- A runner host can be prepared for rootless podman:
  =deploy/runner-podman-setup.sh=, the systemd changes it needs, and
  weekly image pruning. Nothing reads them yet; the preparation ships
  before the runner that requires it, on purpose. Ref #144

** Collaboration

- =mr review request --add = asks a particular person for a
  review, who then carries it in their queue and is notified;
  =--remove= withdraws the ask. The queue was computed from involvement
  alone, so a collaborator who had not touched a thread heard nothing.
  #145
- Bookmarks save someone else's repository to come back to, and the
  count is the instance's only popularity signal. Separate from pins
  rather than a flag on them: a pin is private quick access to your own
  work and drives the rail, a bookmark is public and counted. =repo
  bookmark=, =repo unbookmark=, =repo bookmarks=, a control on the
  repository header, the count in the facts bar, and =/bookmarks=. Read
  access is all a bookmark needs, and one made while a repository was
  public drops out of the listing if it goes private. #146

** The browser catches up

Nine capabilities that were CLI-only and had no reason to be:

- label management — list, create, colour, remove #163
- dependency status under the toggle that turns checks on #164
- release delete #165
- the account export bundle, as a download #166
- organization create and rename; delete stays CLI-only, wanting a
  typed confirmation, and the page says so #167
- opening a merge request from a fork, with a source picker offering
  the branches of every fork you can push to #168
- forking a repository #174
- cancelling a queued or running build #162
- the profile form #161, and a markup picker on issue and merge
  request create #160
- admin table headers align with their columns #151

** CLI

- =gitbay mr rebase = replays a merge request's branch onto its
  target and re-pushes it. A repository requiring signed commits accepts
  only fast-forward merges, so a merge request whose target moved had to
  be rebased by hand; the refusal already named the procedure and this
  runs it. The git work is local, so the replayed commits carry your
  signature — the server still holds no key. A branch in a fork is
  refused, naming the repository to run it in. #175

** Security

- Mutating commands are bounded per account in the dispatcher, so SSH,
  the JSON API and the web spend one budget and a caller cannot refresh
  it by changing surface. Authentication failures were throttled;
  commands were not. #148
- A dead-lettered mail is logged by its queue row id, not the recipient
  address, and the relay's error is redacted before logging because a
  rejection quotes the address it rejected. The address stays on the
  row, where =/admin= shows it. One rule for every mail type. #173
- The 2026-09 sweep's coverage — and what it did not reach — is recorded
  in the wiki's Threat-Model rather than in a closed issue. #149

** Dependencies

- goldmark 1.8.6, sqlite 1.58.0. #140

** Upgrading

Replace the binary and reinstall the CLI. One migration (0043,
bookmarks), applied on start.

=limits.write_rate= defaults to 60 mutating commands a minute per
account. A script that writes faster will be refused with exit 4 and a
retry time; raise it in =[limits]=, or slow the script. Read-only
commands, the runner protocol and the host CLI are not counted.

A companion =.wiki= repository is no longer read. Move its pages
to =.gitbay/wiki/= on the default branch.

Runner isolation is half done. Builds still run as the runner's user
with no container: the step environment no longer leaks the service's,
and a host can be prepared for podman, but nothing yet executes a build
in one. Do not enable CI for repositories you do not trust. #144 stays
open for the execution half.

* v1.13.3 — 2026-09-05

Only a writer's review decides a merge gate, and a scan of one branch
stops overwriting another's results.

- =mr review= resolves with =CanRead= and applied no further check, and
  the merge gates counted every fresh verdict. On a public repository
  that let anyone with an account satisfy =require_approvals= — defeating
  four-eyes review by holding two accounts, which on an instance with
  open registration means defeating it outright — and equally let a
  passer-by block a merge the owner wanted, with no override and only a
  force-push to clear it. Both were confirmed against a running instance.
  Reviewing stays open to every reader, because an outside opinion on a
  public change is worth having; it no longer decides the gate. What
  does is write access, the same question the gates already answer. An
  uncounted review is marked advisory by =mr show= and by the merge
  request page, both reading the same rule, so the page cannot show an
  approval the gate ignores. #147
- The two =git upload-pack= spawns behind the smart HTTP transport now
  resolve git at start-up like everything else. The v1.13.2 rewrite
  matched a plain identifier for the context and these pass
  =r.Context()=, so it skipped them. #153
- CI analyses each branch as itself. Every scan was recorded against the
  project's main branch whatever branch produced it, so a feature branch
  replaced main's results — a branch that removes findings made main look
  clean before the fix merged, and one that added them made main look
  broken when it was not. #154

Replace the binary and reinstall the CLI. No migration.

A repository relying on approvals from accounts without write access
will find those merges now refused. That was the defect: such an approval
never meant the repository accepted the change. Grant write, or name the
reviewer in CODEOWNERS and grant write, if their approval is meant to
count.

* 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