krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
c0d45bd13028b5cad90bac2dd1efad6390908e0d
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-24T01:45:52Z
README.org | 6 ++ docs/admin.org | 140 ++++++++++++++++++++++++++++++++++++++++++ docs/api.org | 109 +++++++++++++++++++++++++++++++++ docs/users.org | 190 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 445 insertions(+) @@ -100,6 +100,12 @@ Every read command takes =--json=; stdout is data, stderr is messages; exit codes are stable (0 ok, 2 usage, 3 not found, 4 denied). Man pages via =gitbay man=, completions via =gitbay completion <shell>=. +* Documentation + +- [[file:docs/users.org][user guide]] — accounts, keys, verified commits, repos, issues, MRs, scripting +- [[file:docs/admin.org][admin guide]] — install, full configuration reference, backup/restore, upgrades +- [[file:docs/api.org][API and webhooks]] — the JSON API contract, tokens, webhook payloads and HMAC + * Development #+begin_src sh new file mode 100644 @@ -0,0 +1,140 @@ +#+title: gitbay admin guide + +One static binary (=gitbayd=), one SQLite file, bare repositories on +disk, and the system =git=. Schema migrations run automatically on +startup and on every admin command. + +* Install + +#+begin_src sh +install -m 755 gitbayd /usr/local/bin/ +adduser --system --group --home /var/lib/gitbay --shell /usr/sbin/nologin gitbay +install -d -o gitbay -g gitbay -m 750 /var/lib/gitbay +gitbayd --config /etc/gitbay/config.toml check-config +#+end_src + +=deploy/= in the source tree has a cloud-init file, a hardened systemd +unit, and a nightly backup timer. Run as the unprivileged =gitbay= user; +the unit's =AmbientCapabilities=CAP_NET_BIND_SERVICE= covers ports +22/80/443 without root. + +** The SSH port decision + +- =ssh.mode = "embedded"= (default): gitbayd itself listens, normally on + 22 — move the host's admin sshd to another port. Remotes read + =git@host:owner/repo= with no port gymnastics. +- =ssh.mode = "system"=: the host sshd owns 22 and invokes gitbayd via + =AuthorizedKeysCommand=: + #+begin_example + AuthorizedKeysCommand /usr/local/bin/gitbayd --config /etc/gitbay/config.toml authorized-keys %t %k + AuthorizedKeysCommandUser gitbay + #+end_example + sshd requires that binary to be root-owned and not group/world + writable. Unknown keys fail authentication inside sshd, so system mode + requires =registration.mode = "closed"= (check-config enforces this). + +* Configuration reference + +=/etc/gitbay/config.toml=. =check-config= validates and names every +contradiction; =--no-host-checks= skips port/path probes. + +** [server] +- =root= (default =/var/lib/gitbay=) — repositories, database, host + keys, ACME cache all live here. +- =site_url= (required) — canonical =https://host=; drives ACME, clone + URLs, mail links. + +** [ssh] +- =mode= — =embedded= | =system= (above). +- =port= (22) — embedded listener port. +- =host_keys= — list of private key paths; empty generates an ed25519 + key at =<root>/ssh/host_ed25519=. + +** [http] +- =addr= (=:443=), =tls= — =acme= | =files= | =off=. +- =acme=: certificates via TLS-ALPN-01 on the HTTPS port, cached at + =<root>/acme=; =acme_email= for the CA account; =acme_http_addr= + (=:80=, ="off"= to disable) adds HTTP-01 and an https redirect — + failing to bind it is a warning, not fatal. Requires an =https://= + site_url with a public DNS name. +- =files=: =cert_file= + =key_file=. +- =off=: plain HTTP — development, or behind a TLS-terminating proxy. + +** [web] +- =mode= — =view_only= (default) | =accounts=. In view_only the mutating + web routes are never registered; in accounts, browser sessions are + minted over SSH (=web login=), and users with write access can create + repos, comment, and make simple file edits (which commit unsigned, + honestly). =password_auth= is reserved and currently rejected. + +** [registration] +- =mode= — =closed= (default) | =invite= | =open=. invite/open require + [mail]. See the user guide for the flows. + +** [mail] +- =smtp_host= (host:port, 587 assumed), =from=, optional =smtp_user= / + =smtp_pass=. STARTTLS when offered. Required for invite/open + registration and self-service =email add=; in closed mode you may omit + it entirely and assert addresses by hand (below). + +** [api] +- =enabled= (false) — the JSON API surface; see =docs/api.org=. Off + means no credential-bearing HTTP endpoint exists at all. + +** [webhooks] +- =allow_local= (false) — permit webhook targets on loopback/private + addresses. Leave off unless you know why you need it (SSRF). + +** [limits] +- =clone_timeout= (3600s) — cap on =repo import= fetches. +- =max_blob_bytes= (100MB) — cap on raw file serving over the web. +- =max_pack_bytes=, =ssh_auth_rate= — reserved, not yet enforced. + +** [git_daemon] +- =enabled= (false), =port= (9418) — the anonymous =git://= listener. + Serves only public repositories that additionally ran + =repo settings git-daemon <repo> on=. + +* Users, email, invites + +#+begin_src sh +gitbayd admin user create alice --key alice.pub --email a@example.org --verified [--admin] +gitbayd admin email verify alice a@example.org # admin assertion, no SMTP needed +gitbayd admin invite --email b@example.org # mails a code; prints it if no SMTP +#+end_src + +"Verified" means SMTP-confirmed or host-admin-asserted; the database +records which. Verified emails are what make commit signatures +meaningful — an unverified address never produces a =verified= badge. + +* Backup and restore + +#+begin_src sh +gitbayd admin backup --out /var/backups/gitbay/backup.tar.gz +#+end_src + +One archive: a consistent SQLite snapshot (taken *before* the +repositories are read, so the database never references objects the +archive missed), every repository, and the SSH host keys. Excluded: +hook socket, regenerated hook scripts, WAL files. Safe to run against a +live daemon. + +Restore: extract into an empty directory, point =server.root= at it, +start gitbayd. Host keys are preserved, so clients keep their +known_hosts entries; hooks regenerate at startup. + +* Upgrades + +Replace the binary, restart the unit. Migrations apply automatically and +are transactional; hook scripts under =<root>/hooks= are rewritten at +startup to point at the current binary path. + +* Odds and ends + +- deleting a fork marks MRs sourced from it =source_gone=; their diffs + remain viewable and mergeable because the target repo owns the + objects. +- =refs/merge-requests/*= is server-owned and unpushable by clients. +- audit-relevant activity (issue/MR lifecycle, imports, pushes) lands in + the =events= table, which also feeds webhooks. +- the daemon idles under 10MB RSS; the smallest VPS tier is adequate. new file mode 100644 @@ -0,0 +1,109 @@ +#+title: gitbay API and webhooks + +* The JSON API + +One endpoint fronts the entire command registry — everything the SSH +control plane can do, current and future, with identical semantics. +Disabled by default; the instance must set: + +#+begin_src toml +[api] +enabled = true +#+end_src + +** Tokens + +Tokens are minted over SSH and only over SSH — an API token can never +create further credentials. + +#+begin_src sh +gitbay auth token create --name ci [--scope full|read] [--ttl 30d] +gitbay auth token list +gitbay auth token revoke ci +#+end_src + +The token (prefix =gb_=, shown exactly once) is presented as +=Authorization: Bearer gb_...=. Only a hash is stored server-side. +Scope =read= permits list/show/log/diff-style commands and refuses +anything that modifies state. =--ttl= takes Go durations or a day +suffix (=30d=); expired, revoked, and unknown tokens all answer the +same 401. + +** POST /api/v1/cmd + +Request body: +#+begin_src json +{"argv": ["issue", "create", "you/project", "--title", "from CI"], + "stdin": "optional body for --file - style input"} +#+end_src + +=argv= is real argv — no shell, no quoting rules. The response is the +command's own JSON envelope with =exit_code= added, plus =stderr= when +the command wrote diagnostics: + +#+begin_src json +{"protocol_version": 1, "exit_code": 0, "data": {"number": 7}} +#+end_src + +HTTP status maps the exit code: 0→200, 2→400, 3→404, 4→403, else 500. +Commands that emit raw text rather than an envelope (=help=, =mr diff=) +come wrapped as ={"output": "..."}=. Git transport commands and the +token commands are refused by name. + +#+begin_src sh +curl -s -H "Authorization: Bearer $TOKEN" \ + -d '{"argv":["whoami"]}' https://gitbay.org/api/v1/cmd + +printf '{"argv":["issue","create","you/project","--title","t","--file","-"],"stdin":"body\n"}' | +curl -s -H "Authorization: Bearer $TOKEN" -d @- https://gitbay.org/api/v1/cmd +#+end_src + +* Webhooks + +Per-repository outbound POSTs for repository events. Managed by repo +admins: + +#+begin_src sh +gitbay webhook add <url> --secret s3cret [--events push,issue.created] # default * +gitbay webhook list +gitbay webhook deliveries [--limit 50] # status, attempts, last error +gitbay webhook redeliver <delivery-id> # requeue, including dead letters +gitbay webhook remove <id> +#+end_src + +** Events + +=push= (data: ref, old, new, forced, deleted), =issue.created=, +=issue.commented=, =issue.closed=, =issue.open=, =mr.created=, +=mr.merged= (data: number, sha), =repo.imported= (data: from). + +** Delivery + +Each event POSTs one JSON body: + +#+begin_src json +{"event": "push", + "repo": "you/project", + "actor": "alice", + "created_at": "2026-08-24T01:00:00.000Z", + "data": {"ref": "refs/heads/main", "old": "...", "new": "...", + "forced": false, "deleted": false}} +#+end_src + +Headers: =X-Gitbay-Event=, =X-Gitbay-Delivery= (id), and — when the hook +has a secret — =X-Gitbay-Signature-256: sha256=<hex hmac>= over the raw +body. Verify before trusting: + +#+begin_src python +import hmac, hashlib +expected = "sha256=" + hmac.new(secret, raw_body, hashlib.sha256).hexdigest() +ok = hmac.compare_digest(expected, request.headers["X-Gitbay-Signature-256"]) +#+end_src + +A 2xx within 10 seconds is success. Anything else retries with +exponential backoff (30s base, doubling) and dead-letters after five +attempts; =webhook deliveries= shows the trail and =redeliver= revives a +dead letter. Redirects are never followed, and targets resolving to +loopback/private/link-local addresses are refused both at registration +and again at connect time, unless the instance sets +=[webhooks] allow_local=. new file mode 100644 @@ -0,0 +1,190 @@ +#+title: gitbay user guide + +Everything here works from stock OpenSSH — replace =gitbay= with +=ssh git@<host>= in any command and it behaves identically. The CLI adds +convenience (instance profiles, repo inference, =$EDITOR=), nothing more. +=ssh git@<host> help= lists every command the server knows. + +* Getting an account + +How you join depends on the instance's registration mode: + +- closed :: an admin creates your account on the host and registers your + first SSH key. Nothing for you to do but hand over your public key. +- invite :: you receive a single-use code by email. With the SSH key you + want to use: + #+begin_src sh + ssh git@<host> register --username you --invite <code> + #+end_src + Your account is active immediately; the invited address is your + verified email. +- open :: + #+begin_src sh + ssh git@<host> register --username you --email you@example.org + #+end_src + A verification code arrives by mail. Until you run + =ssh git@<host> email verify <code>=, the account is pending: you can + run =whoami= and the email commands, and nothing else — no git, no + repos. + +* SSH keys + +Your key is your identity; there are no passwords anywhere. The SSH +username is always =git= — the key alone determines who you are. + +#+begin_src sh +gitbay auth keys list +gitbay auth keys add --scope git < ~/.ssh/ci_key.pub # key on stdin +gitbay auth keys remove SHA256:... +#+end_src + +Scopes: =full= (default; git plus every control command) or =git= (git +transport only — right for CI and automation keys, which then cannot +touch issues, settings, or your account). + +A key belongs to exactly one account instance-wide. Registering a key +someone else already holds is refused without telling you whose it is. + +* Verified commits + +The commit badge is driven by the *author* email and the signing key: +=verified= means the signature is valid, the key is registered to an +account, and the author email is a verified address on that account. + +For OpenPGP signing (git's default): +#+begin_src sh +gpg --armor --export you@example.org | gitbay auth pgp add +#+end_src + +For SSH signing (=git config gpg.format ssh=): sign with any key +registered on your account; your verified addresses act as the principal +set. No separate registration step. + +Add and verify additional addresses with =email add <address>= / +=email verify <code>= (requires the instance to have SMTP; otherwise an +admin can assert an address for you). + +The states you will see, in decreasing order of trust: =verified=, +=signed_unknown_key= (valid signature, key not registered here — register +it and history upgrades retroactively), =signed_email_mismatch= (real +key, author line claims someone else), =signed_key_expired= / +=signed_key_revoked=, =bad_signature=, =unsigned=. Server-created commits +(web edits, merge commits) are always =unsigned= — the server holds no +signing key on principle. + +* Repositories + +#+begin_src sh +gitbay repo create you/project [--private] +gitbay repo clone you/project +gitbay repo list +gitbay repo show you/project +gitbay repo log you/project --limit 20 # commits with signature states +gitbay repo fork other/project [--name mine] +gitbay repo delete you/project --yes +#+end_src + +Pushing is SSH-only. Public repositories are anonymously readable over +HTTPS (and =git://= where enabled); private repositories exist only over +SSH and answer "not found" to everyone without access. + +Access and settings (owner or =admin= grant): +#+begin_src sh +gitbay repo access grant you/project alice write # read | write | admin +gitbay repo access revoke you/project alice +gitbay repo settings protect you/project main # no force-push, no delete +gitbay repo settings require-signed you/project on # every commit must verify +gitbay repo settings git-daemon you/project on # expose over git:// +#+end_src + +Import from another forge (git data only — issues and PRs do not +transfer): +#+begin_src sh +gitbay repo import you/mirror --from https://github.com/you/repo.git \ + [--private] [--token-stdin] # token on stdin, never in the URL +#+end_src + +* Organizations + +Orgs share the owner namespace with users and own repositories at +=org/repo=. Members get write on all org repos; org admins get repo +admin, create repos under the org, and manage membership. + +#+begin_src sh +gitbay org create krz +gitbay org members add krz alice [--role admin] +gitbay org show krz +gitbay org rename krz newname # clone URLs change +gitbay org delete krz --yes # only when it owns no repositories +#+end_src + +* Issues + +Anyone who can read a repository can file and comment. Closing/reopening +is for the author or anyone with write; labels and assignees need write. + +#+begin_src sh +gitbay issue create --title "it breaks" [--body "..." | --file -] +gitbay issue list [--state open|closed|all] +gitbay issue show 4 +gitbay issue comment 4 --message "same here" +gitbay issue close 4 / reopen 4 +gitbay issue label 4 --add bug --remove wontfix +gitbay issue assign 4 --add alice +#+end_src + +Inside a clone, the repository is inferred from the =origin= remote — +that is why no =owner/name= appears above. Anywhere else, pass it as the +first argument. Long text: =--body= inline, =--file -= from stdin, or +neither on a terminal and =$EDITOR= opens. + +* Merge requests + +#+begin_src sh +gitbay mr create --source feature --target main --title "add thing" +gitbay mr create other/upstream --source you/fork:feature --target main --title "..." +gitbay mr list / show 4 / diff 4 +gitbay mr checkout 4 # local branch mr/4 from the MR head +gitbay mr review 4 --approve # or --request-changes / --comment +gitbay mr merge 4 [--strategy ff|merge|squash|rebase] +gitbay mr close 4 +#+end_src + +Semantics worth knowing: + +- the MR head lives in the *target* repository as + =refs/merge-requests/N/head= (fetchable by any reader), so an MR + survives deletion of its source branch or fork. +- force-pushing the source updates the MR and marks existing reviews + stale. +- default strategy: fast-forward when possible, else a merge commit. + Squash makes one commit authored by the MR author, committed by the + merger. Rebase replays a linear range preserving authors; it refuses + ranges containing merge commits, and when fast-forward is possible it + *is* one (original commits and signatures land untouched). +- on =require_signed_commits= branches only fast-forwards of fully + verified commits merge; everything server-created is refused with + instructions to rebase locally. + +* Scripting + +Every read command takes =--json= and emits one envelope: +={"protocol_version": 1, "data": ...}=. stdout is data, stderr is +messages. Exit codes are stable: 0 ok, 1 failure, 2 usage, 3 not found, +4 denied, 5 server/protocol error. Nothing ever prompts; destructive +commands take =--yes=. + +For HTTP automation see =docs/api.org=. + +* CLI setup + +#+begin_src sh +gitbay remote add myforge forge.example.org [--port n] [--user u] --default +gitbay remote list +gitbay init [name] [--private] # git init + repo create + origin, in one step +#+end_src + +Configuration lives at =~/.config/gitbay/config.toml=. The CLI shells +out to your real =ssh=, so =~/.ssh/config=, the agent, and hardware keys +all apply. Man pages: =gitbay man --dir <dir>=; completions: +=gitbay completion bash|zsh|fish=.