Import the wiki into .gitbay/wiki !264

merged merged by cmc on 2026-09-05 06:30 UTC · krz/gitbay:wiki-migrate into main

11 files changed, +2015 −0

Layout: unified · split

.gitbay/wiki/API.org added +186
@@ -0,0 +1,186 @@
1#+title: gitbay API and webhooks
2
3* The JSON API
4
5One endpoint fronts the entire command registry — everything the SSH
6control plane can do, current and future, with identical semantics.
7Disabled by default; the instance must set:
8
9#+begin_src toml
10[api]
11enabled = true
12#+end_src
13
14** Tokens
15
16Tokens are minted over SSH and only over SSH — an API token can never
17create further credentials.
18
19#+begin_src sh
20gitbay auth token create --name ci [--scope full|read] [--ttl 30d]
21gitbay auth token list
22gitbay auth token revoke ci
23#+end_src
24
25The token (prefix =gb_=, shown exactly once) is presented as
26=Authorization: Bearer gb_...=. Only a hash is stored server-side.
27Scope =read= permits list/show/log/diff-style commands and refuses
28anything that modifies state. =--ttl= takes Go durations or a day
29suffix (=30d=); expired, revoked, and unknown tokens all answer the
30same 401.
31
32** POST /api/v1/cmd
33
34Request body:
35#+begin_src json
36{"argv": ["issue", "create", "you/project", "--title", "from CI"],
37 "stdin": "optional body for --file - style input"}
38#+end_src
39
40=argv= is real argv — no shell, no quoting rules. The response is the
41command's own JSON envelope with =exit_code= added, plus =stderr= when
42the command wrote diagnostics:
43
44#+begin_src json
45{"protocol_version": 1, "exit_code": 0, "data": {"number": 7}}
46#+end_src
47
48HTTP status maps the exit code: 0→200, 2→400, 3→404, 4→403, else 500.
49Commands that emit raw text rather than an envelope (=help=, =mr diff=)
50come wrapped as ={"output": "..."}=. Git transport commands and the
51token commands are refused by name.
52
53#+begin_src sh
54curl -s -H "Authorization: Bearer $TOKEN" \
55 -d '{"argv":["whoami"]}' https://gitbay.org/api/v1/cmd
56
57printf '{"argv":["issue","create","you/project","--title","t","--file","-"],"stdin":"body\n"}' |
58curl -s -H "Authorization: Bearer $TOKEN" -d @- https://gitbay.org/api/v1/cmd
59#+end_src
60
61** GET /api/v1/read
62
63The conditional half: the same registry over GET, admitting only
64commands the registry marks read-only, so a GET structurally cannot
65mutate. Responses carry an =ETag=; =If-None-Match= answers 304.
66
67#+begin_src sh
68curl -s -H "Authorization: Bearer $TOKEN" \
69 "https://gitbay.org/api/v1/read?argv=repo&argv=show&argv=you/project"
70#+end_src
71
72** The dashboard read
73
74=dashboard= returns the account aggregate — pinned repositories, open
75merge requests, assigned issues, recent builds — in one call:
76
77#+begin_src sh
78curl -s -H "Authorization: Bearer $TOKEN" \
79 "https://gitbay.org/api/v1/read?argv=dashboard"
80#+end_src
81
82For an instance admin the response also carries ={"server": {"commit":
83"<sha>"}}=, the build the daemon is running, so the deployed commit is
84readable without the journal. The key is absent for everyone else: the
85exact build a host runs narrows down which known issues apply to it.
86
87** Cursor pagination
88
89=issue list=, =mr list=, =repo list=, and =feed= accept =--limit <n>=
90(1–200) and =--cursor <c>=. With either flag present the =data=
91envelope becomes ={"items": [...], "next": "..."}=; =next= is an
92opaque cursor for the following page, absent on the last one. Without
93the flags the response stays the complete bare array; a bare =feed=
94returns the newest 50 events.
95
96* Commit statuses (CI reporting)
97
98CI reports results through the same command surface (over SSH or the
99JSON API with a full-scope token; reporting requires write access):
100
101#+begin_src sh
102gitbay status set <owner/name> <sha> --context build --state pending
103gitbay status set <owner/name> <sha> --context build --state success --url https://ci.example/run/1
104gitbay status list <owner/name> <sha> --json # {"combined": "...", "statuses": [...]}
105#+end_src
106
107One row per (commit, context): re-reporting updates in place. States:
108=pending=, =success=, =failure=, =error=; the combined state is the
109worst of them. Statuses appear on commit pages, MR pages, and
110=mr show=, each with =updated_at=; a =ci/<job>= status also carries
111=duration=, read from the build behind it, once that build has
112finished. With =repo settings require-checks <repo> on=, merging
113requires the MR head to carry statuses and all of them green. Each
114report also emits a =status= event to webhooks.
115
116* Webhooks
117
118Per-repository outbound POSTs for repository events. Managed by repo
119admins:
120
121#+begin_src sh
122gitbay webhook add <url> --secret s3cret [--events push,issue.created] # default *
123gitbay webhook list
124gitbay webhook deliveries [--limit 50] # status, attempts, last error
125gitbay webhook redeliver <delivery-id> # requeue, including dead letters
126gitbay webhook remove <id>
127#+end_src
128
129** Events
130
131Every event this forge records, and so every name =--events= may take.
132The server holds the same list as =control.EventKinds=, and a test
133fails if the code emits something not listed or lists something it never
134emits — so this is the whole set, not a sample. =webhook add= refuses a
135name that is not one of them, since a subscription to a typo would
136silently never fire.
137
138- repository: =push= (ref, old, new, forced, deleted), =repo.archived=,
139 =repo.unarchived=, =repo.imported= (from)
140- issues: =issue.created=, =issue.edited=, =issue.commented=,
141 =issue.closed=, =issue.open=, =issue.labeled= (labels),
142 =issue.assigned= (assignees), =issue.milestoned= (milestone)
143- merge requests: =mr.created=, =mr.edited=, =mr.commented=,
144 =mr.reviewed= (verdict), =mr.draft= (draft), =mr.retargeted= (from,
145 to), =mr.milestoned= (milestone), =mr.merged= (number, sha),
146 =mr.closed=
147- releases: =release.created= (tag), =release.deleted= (tag)
148- CI: =status=, =build.success=, =build.failure=, =build.cancelled=
149
150Every payload carries =number= where it names an issue or merge request.
151
152There is deliberately no =repo.deleted=. Both =events.repo_id= and
153=webhooks.repo_id= cascade from =repos=, so recording one would delete
154it — and every webhook that could have received it — in the same
155statement. A repository's deletion is visible in the audit log.
156
157** Delivery
158
159Each event POSTs one JSON body:
160
161#+begin_src json
162{"event": "push",
163 "repo": "you/project",
164 "actor": "alice",
165 "created_at": "2026-08-24T01:00:00.000Z",
166 "data": {"ref": "refs/heads/main", "old": "...", "new": "...",
167 "forced": false, "deleted": false}}
168#+end_src
169
170Headers: =X-Gitbay-Event=, =X-Gitbay-Delivery= (id), and — when the hook
171has a secret — =X-Gitbay-Signature-256: sha256=<hex hmac>= over the raw
172body. Verify before trusting:
173
174#+begin_src python
175import hmac, hashlib
176expected = "sha256=" + hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
177ok = hmac.compare_digest(expected, request.headers["X-Gitbay-Signature-256"])
178#+end_src
179
180A 2xx within 10 seconds is success. Anything else retries with
181exponential backoff (30s base, doubling) and dead-letters after five
182attempts; =webhook deliveries= shows the trail and =redeliver= revives a
183dead letter. Redirects are never followed, and targets resolving to
184loopback/private/link-local addresses are refused both at registration
185and again at connect time, unless the instance sets
186=[webhooks] allow_local=.
.gitbay/wiki/Admin.org added +464
@@ -0,0 +1,464 @@
1#+title: gitbay admin guide
2
3One static binary (=gitbayd=), one SQLite file, bare repositories on
4disk, and the system =git=. Schema migrations run automatically on
5startup and on every admin command.
6
7* Install
8
9Build from source (=go build ./cmd/gitbayd=), install via the vanity
10module path (=go install gitbay.org/gitbay/cmd/gitbayd@latest=), or use
11a release build: =deploy/release.sh <tag>= cross-compiles reproducible
12linux/amd64, linux/arm64, and darwin/arm64 binaries with a SHA256SUMS
13manifest (CGO off, trimpath, stripped — byte-identical per commit and
14toolchain).
15
16#+begin_src sh
17install -m 755 gitbayd /usr/local/bin/
18adduser --system --group --home /var/lib/gitbay --shell /usr/sbin/nologin gitbay
19install -d -o gitbay -g gitbay -m 750 /var/lib/gitbay
20gitbayd --config /etc/gitbay/config.toml check-config
21#+end_src
22
23=deploy/= in the source tree has a cloud-init file, a hardened systemd
24unit, and a nightly backup timer. Run as the unprivileged =gitbay= user;
25the unit's =AmbientCapabilities=CAP_NET_BIND_SERVICE= covers ports
2622/80/443 without root.
27
28** The SSH port decision
29
30- =ssh.mode = "embedded"= (default): gitbayd itself listens, normally on
31 22 — move the host's admin sshd to another port. Remotes read
32 =git@host:owner/repo= with no port gymnastics.
33- =ssh.mode = "system"=: the host sshd owns 22 and invokes gitbayd via
34 =AuthorizedKeysCommand=:
35 #+begin_example
36 AuthorizedKeysCommand /usr/local/bin/gitbayd --config /etc/gitbay/config.toml authorized-keys %t %k
37 AuthorizedKeysCommandUser gitbay
38 #+end_example
39 sshd requires that binary to be root-owned and not group/world
40 writable. Unknown keys fail authentication inside sshd, so system mode
41 requires =registration.mode = "closed"= (check-config enforces this).
42
43* Configuration reference
44
45=/etc/gitbay/config.toml=. =check-config= validates and names every
46contradiction; =--no-host-checks= skips port/path probes.
47=gitbayd admin config show= prints the configuration in effect as TOML,
48every default filled in and =smtp_pass= redacted. A file that fails
49validation still prints, followed by the contradiction.
50
51** [server]
52- =root= (default =/var/lib/gitbay=) — repositories, database, host
53 keys, ACME cache all live here.
54- =site_url= (required) — canonical =https://host=; drives ACME, clone
55 URLs, mail links.
56- =source_repo= (optional, =owner/name=) — the repository this instance
57 develops itself in. Startup warns when the running build's commit is
58 not on that repository's default branch, which is how a binary built
59 from an unmerged branch stops being invisible. Leave it unset unless
60 the instance hosts its own source.
61
62** [ssh]
63- =mode= — =embedded= | =system= (above).
64- =port= (22) — embedded listener port.
65- =host_keys= — list of private key paths; empty generates an ed25519
66 key at =<root>/ssh/host_ed25519=.
67
68** [http]
69- =addr= (=:443=), =tls= — =acme= | =files= | =off=.
70- =acme=: certificates via TLS-ALPN-01 on the HTTPS port, cached at
71 =<root>/acme=; =acme_email= for the CA account; =acme_http_addr=
72 (=:80=, ="off"= to disable) adds HTTP-01 and an https redirect —
73 failing to bind it is a warning, not fatal. Requires an =https://=
74 site_url with a public DNS name.
75- =files=: =cert_file= + =key_file=.
76- =off=: plain HTTP — development, or behind a TLS-terminating proxy.
77
78=trusted_proxies= lists the addresses or CIDRs of reverse proxies in
79front of the daemon. A request from one of them is attributed, for API
80rate limiting, to the last =X-Forwarded-For= hop that is not itself a
81trusted proxy; from anyone else the header is ignored. Empty, the
82default, is right when gitbayd terminates TLS itself.
83
84** [web]
85- =mode= — =view_only= (default) | =accounts=. In view_only the mutating
86 web routes are never registered; in accounts, browser sessions are
87 minted over SSH (=web login=), and users with write access can create
88 repos, comment, and make simple file edits (which commit unsigned,
89 honestly). =password_auth= is reserved and currently rejected.
90
91** [registration]
92- =mode= — =closed= (default) | =invite= | =open=. invite/open require
93 [mail]. See the user guide for the flows.
94
95- =pending_expiry= (empty, never) — a duration such as ="168h"=; a
96 self-registered account still unverified after that long is removed,
97 hourly and at start, audited as =pending.expired=.
98
99** [mail]
100- =smtp_host= (host:port, 587 assumed), =from=, optional =smtp_user= /
101 =smtp_pass=. STARTTLS when offered. Required for invite/open
102 registration and self-service =email add=; in closed mode you may omit
103 it entirely and assert addresses by hand (below).
104
105** [api]
106- =enabled= (false) — the JSON API surface; see [[API]]. Off
107 means no credential-bearing HTTP endpoint exists at all.
108
109** [webhooks]
110- =allow_local= (false) — permit webhook targets on loopback/private
111 addresses. Leave off unless you know why you need it (SSRF).
112
113** [limits]
114- =clone_timeout= (3600s) — cap on =repo import= fetches.
115- =max_blob_bytes= (100MB) — cap on raw file serving over the web.
116- =max_asset_bytes= (512MB) — cap per uploaded release asset.
117- =max_repos_per_user= (0, unlimited) — repositories an account may own
118 directly; =repo create=, =fork= and =import= refuse past it.
119 Organizations are not capped.
120- =max_bytes_per_user= (0, unlimited) — disk the account's own
121 repositories may take; a push may be no larger than what is left.
122- =max_pack_bytes=, =ssh_auth_rate= — reserved, not yet enforced.
123
124** [git_daemon]
125- =enabled= (false), =port= (9418) — the anonymous =git://= listener.
126 Serves only public repositories that additionally ran
127 =repo settings git-daemon <repo> on=.
128
129** [mirrors]
130- =pull_interval_minutes= (15) — how often pull mirrors fetch their
131 upstream. Push mirrors sync shortly after each local ref update.
132 Mirror URLs pass the same SSRF rules as webhook targets.
133
134** [go_import]
135Vanity Go module paths, one per line: ="host/module" = "owner/repo"=.
136Requests with =?go-get=1= at or under the module path answer with the
137go-import meta tag pointing at the repository's HTTPS clone URL, so
138=go install host/module/cmd/...@latest= resolves. The repository should
139be public (the module path itself confirms it exists).
140
141* Users, email, invites
142
143Every =gitbayd admin= subcommand except =backup=, =gc= and the one-shot
144backfills is a wrapper that dispatches the registry command of the same
145name as the host: an admin context with no account behind it, so its
146audit rows carry no actor and =source: host=. The same commands run in an
147instance admin's SSH session (=ssh git@<host> admin ...=) and write the
148same rows with the key fingerprint as source. One implementation, two
149credentials.
150
151#+begin_src sh
152gitbayd admin user create alice --key alice.pub --email a@example.org --verified [--admin]
153gitbayd admin email verify alice a@example.org # admin assertion, no SMTP needed
154gitbayd admin invite --email b@example.org # mails a code; prints it if no SMTP
155#+end_src
156
157"Verified" means SMTP-confirmed or host-admin-asserted; the database
158records which. Verified emails are what make commit signatures
159meaningful — an unverified address never produces a =verified= badge.
160
161* Audit and account control
162
163The audit log is the security feed (events are the product feed): every
164successful mutating command with its argv and source credential (SSH key
165fingerprint or API), registrations, admin actions, force-pushes, and
166auth failures/throttling. Secrets never appear — they travel on stdin,
167never in argv.
168
169#+begin_src sh
170gitbayd admin audit [--actor u|-] [--action prefix] [--since 24h|7d|date] [--limit n] [--json]
171ssh git@<host> audit ... # the same, from an admin session (SSH only)
172ssh git@<host> admin user list [--state active|pending|disabled|admin]
173ssh git@<host> admin user show <name> # keys, emails, orgs, tokens, sessions
174ssh git@<host> admin user limits <name> [--repos n|default] [--bytes n|default] # per-account caps
175ssh git@<host> admin user promote <name> # grant instance admin
176ssh git@<host> admin user demote <name> # remove it; the last admin is refused
177gitbayd admin user promote <name> # host-local: recovery when no admin key is reachable
178gitbayd admin user disable <name> # suspend: SSH, web, API all refused;
179gitbayd admin user enable <name> # sessions dropped, nothing deleted
180gitbayd admin user delete <name> --yes # only for accounts anchoring nothing:
181 # refused (with each blocker named) while
182 # the account owns repos, authored
183 # issues/MRs/comments/reviews, or is an
184 # org's only admin
185#+end_src
186
187=--actor= takes a username, or =-= for rows with no actor: host commands
188and auth failures. =--action= is a prefix, so =cmd repo= catches every
189repository command and =admin= every host or admin-session action.
190=--since= is a duration back from now (=30m=, =24h=, =7d=) or a date.
191
192=admin user list= pages by username (=--limit=, =--cursor=) and carries
193each account's state and =last_seen=, the newest use of any of its SSH
194keys or API tokens. =admin user show= adds the keys with their last use,
195each address with how it was verified, PGP keys, org roles, the owned
196repository count, API token names, and live browser sessions. Both are
197SSH-only and refused to non-admins, like =audit=.
198
199Promotion needs an active account: a pending or disabled one is refused.
200Demotion is refused when it would leave no admin, over SSH and on the
201host alike, so the host-local =promote= is the way back in when the only
202admin key is lost.
203
204Instance admin carries no right on anyone's repository: policy does not
205consult it, and a private repository still answers not-found to an
206admin. Moderation goes through explicit overrides that skip the access
207check and write their own audit row:
208
209#+begin_src sh
210ssh git@<host> admin repo list [--owner o] [--visibility public|private] # size, last push
211ssh git@<host> admin repo archive|unarchive <owner/name>
212ssh git@<host> admin repo visibility <owner/name> public|private
213ssh git@<host> admin repo delete <owner/name> --yes
214#+end_src
215
216Each lands in the audit log as =admin repo.<action>= naming the
217repository, on top of the =cmd= row every mutating command gets.
218
219=limits.ssh_auth_rate= (10) throttles per-IP authentication *failures*
220per minute — successful auths never count and clear the slate.
221=limits.max_pack_bytes= is enforced as =receive.maxInputSize= on every
222push.
223
224* Queues
225
226Every background worker keeps a backlog and a failure state. An instance
227admin reads them all in one place:
228
229#+begin_src sh
230gitbay dashboard --json | jq .queues # webhooks, mail, mirrors, builds, deps
231#+end_src
232
233Per worker: pending, retrying (pending with a failed attempt) and
234dead-lettered counts with the oldest pending age, and the retrying or
235failed rows themselves, capped at twenty each. Builds list what is
236running and since when; mirrors list the ones whose last sync failed;
237dependency checks list the ones whose last check errored. Non-admins get
238no =queues= key at all.
239
240In accounts mode the same read renders at =/admin=, linked from the rail
241for admins. Anyone else gets a 404 there.
242
243* Maintenance
244
245#+begin_src sh
246gitbayd admin stats [--json] # counts, database size, per-repo disk
247ssh git@<host> admin stats [--json] # the same, from an admin session
248gitbayd admin gc [--repo owner/name] # git gc: repack and prune; per-repo sizes
249gitbayd admin gc --aggressive # thorough repack; slow, rarely needed
250gitbayd admin gc --lfs # also drop LFS objects no pointer names (older than a day)
251#+end_src
252
253=deploy/cloud-init.yaml= ships a =gitbay-gc.timer= that runs =admin gc=
254weekly (Sunday 07:00 UTC). Imported repositories keep whatever pack
255layout the source sent, so a first manual =admin gc= after a bulk
256import is worthwhile.
257
258* Backup and restore
259
260#+begin_src sh
261gitbayd admin backup --out /var/backups/gitbay/backup.tar.gz
262gitbayd admin backup --verify /var/backups/gitbay/backup.tar.gz # read it back
263#+end_src
264
265One archive: a consistent SQLite snapshot (taken *before* the
266repositories are read, so the database never references objects the
267archive missed), every repository, and the SSH host keys. Excluded:
268hook socket, regenerated hook scripts, WAL files. Safe to run against a
269live daemon.
270
271=--verify= reads an archive back: the snapshot must pass SQLite's
272integrity check, and every repository the snapshot names must be in the
273archive. A database-only archive is checked for integrity and says so.
274Exit is non-zero on damage or a missing repository.
275
276Restore: extract into an empty directory, point =server.root= at it,
277start gitbayd. Host keys are preserved, so clients keep their
278known_hosts entries; hooks regenerate at startup.
279
280** Schedule and recovery point
281
282Two timers, because the two halves of the data have different exposure.
283
284- =gitbay-backup.timer=, nightly. The full archive above, last 7 kept.
285- =gitbay-db-backup.timer=, hourly. =admin backup --db-only=, which
286 writes the SQLite snapshot alone, last 48 kept. A few MB against the
287 full archive's hundreds, which is what makes the frequency affordable.
288
289The split follows what a loss would actually cost. Repositories are git,
290so a mirror or any clone is a second copy; the database is the only copy
291of issues, merge requests, comments and review state. So the recovery
292point is about an hour for the data that exists nowhere else, and a day
293for the data that does.
294
295Continuous replication (litestream and similar) was considered and not
296adopted. It would take the database's recovery point to seconds, but the
297repositories would still be on the nightly archive, so a restore could
298produce a database referencing commits the repository backup does not
299have. Consistency between the two halves is worth more here than latency
300on one of them. Revisit if repository replication becomes continuous
301too.
302
303* Upgrades
304
305Replace the binary, restart the unit. Migrations apply automatically and
306are transactional; hook scripts under =<root>/hooks= are rewritten at
307startup to point at the current binary path.
308
309* CI runner
310
311=gitbay-runner= executes builds queued by pushes and merge requests. It
312polls over SSH with a key added by =keys add --scope runner=, which
313reaches only the runner protocol and read-only git (a runner executes
314arbitrary repository code, so the key it holds must not do more), then
315clones, runs the steps, streams the log back and resolves the commit
316status. Run it as a dedicated unprivileged user on a non-admin account.
317=admin user create --key= registers a full-scope key, so the runner key
318is added afterwards through a bootstrap key that is then removed:
319
320#+begin_src sh
321useradd --system --create-home --home-dir /var/lib/gitbay-runner ci-runner
322sudo -u ci-runner ssh-keygen -t ed25519 -N "" -f /var/lib/gitbay-runner/.ssh/id_ed25519
323ssh-keygen -t ed25519 -N "" -f /tmp/ci-bootstrap
324gitbayd --config /etc/gitbay/config.toml admin user create ci --key /tmp/ci-bootstrap.pub
325ssh -i /tmp/ci-bootstrap git@127.0.0.1 keys add --scope runner < /var/lib/gitbay-runner/.ssh/id_ed25519.pub
326ssh -i /tmp/ci-bootstrap git@127.0.0.1 keys remove "$(ssh-keygen -lf /tmp/ci-bootstrap.pub | awk '{print $2}')"
327rm /tmp/ci-bootstrap /tmp/ci-bootstrap.pub
328gitbay-runner -remote git@127.0.0.1 -workdir /var/lib/gitbay-runner/work
329#+end_src
330
331=-jobs N= runs N builds at once. Claiming is one transaction that
332selects and updates, and each build works in its own =build-<id>=
333directory, so workers do not collide; idle polls are staggered across
334the interval so N of them do not wake together. The drop-in's weights
335below are per service, not per build, so raising =-jobs= divides them
336rather than multiplying the host's load.
337
338=admin runners= shows which account each runner polls as, and what each
339is scoped to. A runner with no scope claims builds for *any*
340repository, which on an instance with open registration means running a
341stranger's steps; scope one with =-repos owner/name=. An admin key
342still works for the protocol during a rotation. A merge request head
343from a fork is built in the target repository as untrusted: the claim
344carries no secrets. Same-repository heads were built by their branch
345push and are not built again.
346
347=make deploy-runner= also installs
348=deploy/gitbay-runner.override.conf= as a systemd drop-in: =Nice=10=,
349=CPUWeight=30=, =IOWeight=30=, so a build never starves the host's sshd,
350the daemon or the backup timers, and =NoNewPrivileges=,
351=ProtectSystem=full=, =ProtectKernelTunables=, =ProtectControlGroups=
352and =RestrictSUIDSGID=, so a step cannot reach outside its workspace
353and the runner's home. The e2e suite alone starts sixty
354daemon instances; without the drop-in a deploy's copy over the admin
355sshd stalled. Both deploy targets copy with =rsync --partial=, which
356resumes a stalled transfer.
357
358v1 runs steps directly on the host — no containers — so treat the
359runner machine as executing whatever your users push. Install the
360toolchains your builds need on it.
361
362A runner claims the oldest pending build in the queue, whichever
363repository it belongs to. =-repos= narrows that to named repositories,
364which is what makes a runner outside the server practical — one on a
365machine that should build a single project, or that holds credentials for
366one deployment, no longer picks up a build belonging to someone else. With
367open registration that someone need not be anyone you know.
368
369#+begin_src sh
370gitbay-runner -remote git@gitbay.org -repos krz/site,krz/docs \
371 -workdir /var/lib/gitbay-runner/work
372#+end_src
373
374Naming no repositories is the old behaviour and stays the right choice for
375the runner on the server itself. The scoping is what the runner asks for,
376not an ACL the server holds over it: a runner account is admin by
377necessity, so the boundary is you choosing how to start it.
378
379=gitbay dashboard= and =ssh git@<host> admin runners= list every account
380that has polled as a runner: when it last polled, the =-repos= scope it
381asked for, and the build it holds. A build a runner claimed and never
382reported is failed by the scheduler's minute tick, whether or not any
383runner is still alive.
384
385Instance admin on the runner account only authorizes the claim/report
386protocol; it grants no repo access. A build that pushes back — a pages
387deploy, an archive publish, an automated MR branch — needs an explicit
388grant on that repo: =repo access grant <owner/name> ci write=. Private
389repos likewise need at least read for the clone.
390
391* LFS storage
392
393Objects live content-addressed under =[lfs] root= (default
394=<server.root>/lfs=); =[lfs] max_object_bytes= caps a single object
395(512MB default). Storage sits behind a small interface — an
396S3-compatible backend is a drop-in with the server proxying, and
397presigned URLs a later optimization. LFS objects do not travel with
398push mirrors (mirrors move git refs only), and gc does not yet collect
399orphaned objects.
400
401* Pages
402
403=[pages] domain = "example.site"= serves public repos' =pages= branches
404on =<owner>.<domain>=. DNS needs a wildcard record =*.<domain>= to the
405server; ACME issues per-subdomain certificates on demand (only for
406owners that exist). The domain must not be the site host or a parent of
407it — pages content runs its own scripts and must stay off the forge's
408origin.
409
410Users with repo admin claim custom domains with =repo domain add=.
411Claims activate only after a DNS TXT challenge proves control of the
412domain (=repo domain verify=, audit-logged); pending claims serve
413nothing, get no certificates, and expire after 7 days. ACME issues
414certificates only for verified hosts, so stray DNS pointed at the
415server gets nothing.
416
417* Security
418
419The [[Threat-Model]] file is the reference for what the forge
420trusts and refuses to do. Operational checklist:
421
422- *Software checks.* =deploy/audit.sh= runs =go vet=, =govulncheck=
423 (the module list is deliberately short — review it on each release),
424 and a short fuzz pass over every attacker-facing parser (pkt-line,
425 commit, SSHSIG armor, OpenPGP key, SSH tokenizer). Run it before
426 tagging a release.
427- *Web responses* carry a scripts-forbidden CSP, =X-Frame-Options:
428 DENY=, =nosniff=, =no-referrer=, and HSTS when TLS is on — no
429 configuration needed.
430- *Host sandboxing.* The systemd unit in =deploy/cloud-init.yaml= runs
431 gitbayd unprivileged with =ProtectSystem=strict=, =PrivateDevices=,
432 =LockPersonality=, =MemoryDenyWriteExecute=,
433 =SystemCallFilter=@system-service=, and =RestrictAddressFamilies= to
434 INET/INET6/UNIX. It keeps =CAP_NET_BIND_SERVICE= only, to bind 22/80/443.
435- *OS patches* apply via =unattended-upgrades= (security origins,
436 auto-reboot 04:30 if required).
437- *Admin sshd (2222)* is throttled by =MaxStartups=/=MaxAuthTries= and
438 watched by =fail2ban=; gitbayd's own port 22 is throttled by
439 =limits.ssh_auth_rate= (auth failures per IP per minute).
440- *Monitoring.* =gitbay-monitor.timer= writes a reading hourly to
441journald and, when =/etc/gitbay/monitor.url= exists, posts it to that
442webhook: disk, service, the daemon's own =/healthz= answer, certificate
443expiry, and the age of the newest full backup and database snapshot.
444It exits non-zero on an alert so the unit shows in =systemctl
445--failed=: a stopped service, =/healthz= not answering =ok=, disk ≥ 85%,
446a certificate under 21 days, a full backup over 25 hours old, or a
447database snapshot over 2 hours old.
448
449=GET /healthz= is unauthenticated and cache-free: whether the database
450answers and which commit serves, 503 when it does not.
451- *Database.* =gitbay.db= and its WAL live under =/var/lib/gitbay= (mode
452 0750, owned by =gitbay=). The nightly archive plus provider snapshots
453 are the recovery path; for tighter RPO, add continuous replication
454 (litestream) against the same file — it coexists with the WAL.
455
456* Odds and ends
457
458- deleting a fork marks MRs sourced from it =source_gone=; their diffs
459 remain viewable and mergeable because the target repo owns the
460 objects.
461- =refs/merge-requests/*= is server-owned and unpushable by clients.
462- audit-relevant activity (issue/MR lifecycle, imports, pushes) lands in
463 the =events= table, which also feeds webhooks.
464- the daemon idles under 10MB RSS; the smallest VPS tier is adequate.
.gitbay/wiki/FAQ.org added +14
@@ -0,0 +1,14 @@
1#+title: FAQ
2
3- Where do I log in? :: You do not, for most things. The web is read
4 (and light write) — mint a browser session with =gitbay web login=.
5- Pull requests? :: Merge requests: =gitbay mr create= from a branch or
6 fork; merges are ff/merge/squash/rebase with signature policy.
7- Stacked PRs? :: Target another open merge request's source branch.
8 The forge reads the stack from the branches, retargets what is above
9 when the one below merges, and refuses squash or rebase under a stack.
10 No stack command; the workflow is branches. See
11 [[Stacked-MRs][Stacked merge requests]].
12- Moving from GitHub? :: =gitbay repo import= (git data) then
13 =gitbay repo import-issues= (history); =repo mirror= covers the
14 transition window.
.gitbay/wiki/Home.org added +16
@@ -0,0 +1,16 @@
1#+title: gitbay wiki
2
3CLI-first git forge: SSH is the API, the web is a rendering.
4
5- [[Quickstart][Quickstart]] — clone, push, first issue
6- [[FAQ][FAQ]] — common questions from GitHub migrants
7- [[Users][User guide]] — accounts, keys, verified commits, repos, issues, MRs
8- [[Stacked-MRs][Stacked merge requests]] — dependent merge requests, merged one layer at a time
9- [[Admin][Admin guide]] — install, configuration reference, backup, security
10- [[API][API and webhooks]] — the JSON API contract, tokens, payloads
11- [[Roadmap][Roadmap]] — status, phases, decisions, what is not planned
12- [[Threat-Model][Threat model]] — what the forge trusts and never does
13- [[Parity][Parity]] — what each surface can do, and what stays SSH-only
14- [[Performance][Performance]] — stress-test numbers from importing git.git
15
16This wiki is a git repository: =git clone ssh://git@gitbay.org/krz/gitbay.wiki.git=
.gitbay/wiki/Parity.org added +284
@@ -0,0 +1,284 @@
1#+title: Parity
2
3Which surface can do what. The CLI is meant to be the complete
4interface: every capability exists over SSH, and the other surfaces
5dispatch the same control commands rather than reimplementing them, so
6they cannot drift. This page is updated in the merge request that
7changes a row.
8
9Three surfaces now: the CLI over SSH, the web, and the iOS client
10(krz/gitbay-ios). A =no= in the cli column is a defect, not a
11preference — it means some surface reached around the registry and
12stranded a capability where only it can reach.
13
14* Rule
15
16A capability lands over SSH first. If it belongs to the
17triage/review/respond loop, it lands on the web in the same merge
18request. Anything whose input is a credential — secrets, mirror
19tokens, API tokens — stays SSH-only by design: the web dispatcher
20refuses =SSHOnly= commands outright. Session minting is not one of
21them: what a browser submits to ask for a login link is a username or
22an address, and the credential it gets back travels by mail.
23
24Rows are one page or one action each. Grouped rows hide gaps, twice
25now: "browse, log, blame, search" read as covered while blame had no
26command at all, and "build list, log" read as covered while the job
27list a trigger can name had none (krz/gitbay#50), leaving the picker
28browser-only and the iOS build screen unable to say more than the log.
29
30* Merge requests
31
32| capability | cli | web | ios |
33|------------------------+-----+-----+-----|
34| read, diff, commits | yes | yes | yes |
35| review and check times | yes | yes | yes |
36| who resolved it, when | yes | yes | yes |
37| comment | yes | yes | yes |
38| edit title and body | yes | yes | yes |
39| review (approve etc.) | yes | yes | yes |
40| resolve a thread | yes | yes | yes |
41| comment on a diff line | yes | yes | yes |
42| merge (all strategies) | yes | yes | yes |
43| close | yes | yes | yes |
44| create | yes | yes | yes |
45| draft, ready | yes | yes | no |
46| search title and body | yes | yes | no |
47| create from a fork | yes | no | yes |
48| retarget | yes | yes | no |
49| milestone | yes | yes | yes |
50| choose body markup | yes | no | no |
51| stacked merge requests | yes | yes | no |
52
53Reviews and checks carry the time they last said something, and a
54=ci/<job>= check carries how long its build ran, so a merge request
55reads without opening the build. Statuses posted through =status set=
56have no build and report only the time. A merged or closed merge
57request names who resolved it and when; a row without that stamp — a
58=migrate= import, or one merged before krz/gitbay!137 with no
59=mr.merged= event to backfill from — says neither rather than
60inventing a time.
61
62A merge request whose target is another open merge request's source
63branch is stacked on it: =mr show= and the page say so both ways, and
64when the lower one merges, everything stacked on it is retargeted onto
65what it merged into with its reviews kept. A squash or rebase merge is
66refused while anything is stacked on the merge request, since it would
67rewrite the commits the stack builds on. The iOS client renders the
68retarget but has no stack view yet.
69
70A draft merge request is open but not asking: it does not merge, and it
71does not appear in anyone's review queue. Draft is a flag rather than a
72fifth state, so every =state = 'open'= rule still means what it did.
73Batched review and range-diff (krz/gitbay#111) are not built.
74
75Nothing asks a *particular* person for a review. The review queue is
76computed from involvement — what you own, are granted, or reach through
77an org or team — so a collaborator with write access who has not touched
78a thread hears nothing until they do (krz/gitbay#145).
79
80Creating from a fork works anywhere the source can be typed as
81=owner/name:branch=; only the web lacks it. Retargeting moves an open
82merge request onto another branch of the same repository and stales the
83reviews, since an approval was of the diff against the old branch.
84
85* Issues
86
87| capability | cli | web | ios |
88|---------------------+-----+-----+-----|
89| read, list, filter | yes | yes | yes |
90| filter by label, assignee, author, milestone | yes | yes | no |
91| search title and body | yes | yes | no |
92| create | yes | yes | yes |
93| comment | yes | yes | yes |
94| edit title and body | yes | yes | yes |
95| close and reopen | yes | yes | yes |
96| labels, assignees | yes | yes | yes |
97| milestone | yes | yes | yes |
98| milestone list | yes | yes | yes |
99| labels: list, colour | yes | no | no |
100| choose body markup | yes | no | no |
101
102Labels are created on the fly by =issue label --add= and managed by
103=label list=, =label set <label> --color rrggbb= and =label remove=,
104which takes the label off every issue. The web paints the stored colour
105on every chip and derives one from the name when none is set; it has no
106form for setting one yet.
107
108Issue, MR and release bodies, and their comments, carry the markup they
109were written in — =--format md|org= on create, comment and edit, stored
110alongside the text so changing a preference later cannot reinterpret
111prose that already exists. Every surface *renders* the stored format;
112the =no= above is the absence of a picker on the web form and in the iOS
113composer, both of which write markdown. Diff-line comments have no
114format column and are always markdown.
115
116* Repositories
117
118| capability | cli | web | ios |
119|-----------------------------+-----+-----+-----|
120| browse files | yes | yes | yes |
121| read a file | yes | yes | yes |
122| commit log | yes | yes | yes |
123| commit log at a ref | yes | yes | yes |
124| one commit with its patch | yes | yes | yes |
125| blame | yes | yes | yes |
126| search file contents | yes | yes | yes |
127| compare two refs | yes | yes | no |
128| branches and tags | yes | yes | yes |
129| wiki (read) | yes | yes | yes |
130| download an archive | yes | yes | n/a |
131| edit a file | yes | yes | yes |
132| create | yes | yes | yes |
133| pin | yes | yes | yes |
134| watch, mute | yes | yes | no |
135| settings, protection | yes | yes | yes |
136| topics, website | yes | yes | yes |
137| visibility | yes | yes | yes |
138| archive (read-only flag) | yes | yes | yes |
139| release list, show | yes | yes | yes |
140| release create, edit | yes | yes | yes |
141| build list | yes | yes | yes |
142| build show (one build) | yes | yes | yes |
143| build log | yes | yes | yes |
144| build jobs | yes | yes | yes |
145| build trigger | yes | yes | yes |
146| build cancel | yes | no | no |
147| dependency checks on/off | yes | yes | no |
148| dependency status | yes | no | no |
149| delete, transfer | yes | no | no |
150| release delete | yes | no | no |
151
152No row is web-only any more. Blame, file editing, log at a ref,
153archive, the public listing and the wiki were all in that state — a
154handler reading git or the store directly instead of dispatching a
155command — until krz/gitbay!99, !101 and !102 gave them commands.
156
157=build cancel= withdraws a queued build, or ends a running one: the
158server closes the runner's log session and the runner kills the step
159within a couple of seconds. Cancelling a duplicate of a commit that
160already passed the job puts that result back on the commit. No button
161on the build page yet.
162
163Dependency checks are off until a repository's admin turns them on: the
164check tells a public registry what the repository depends on. =repo deps
165status= lists what is behind and has no web view because the report
166itself is an issue the worker opens, rewrites and closes, which every
167surface already reads.
168
169The wiki row covers reading. Editing a wiki is a push to
170=<repo>.wiki.git= on every surface, including the web, so there is no
171edit row to have parity on.
172
173=n/a= marks a capability deliberately absent from a surface rather than
174missing from it. Archive download is =n/a= on iOS: the read API carries
175a command's stdout as a JSON string, so a gzip stream cannot survive it,
176and the app has nowhere useful to put a tarball — the brief rules out
177local git, so there is no clone, checkout or build to feed. The web's
178route stays the way to get one.
179
180A README or wiki page's org renders per surface: the web through
181go-org, the iOS client through the shared OrgSwift package
182(=krz/org-swift=). OrgSwift is held to orgo's output by the
183=krz/org-conformance= corpus — golden renderings from orgo, itself
184diffed against Emacs =ox-html= — so constructs the iOS client used to
185approximate (tables, footnotes, timestamps, heading tags, nested lists)
186now render the way the reference does. go-org is not yet on the corpus.
187
188* Discovery
189
190| capability | cli | web | ios |
191|----------------------------------+-----+-----+-----|
192| search repositories | yes | yes | yes |
193| search issues and merge requests | yes | yes | no |
194| browse all public repositories | yes | yes | yes |
195| profile page | yes | yes | yes |
196| profile about and links | yes | yes | no |
197| activity feed | yes | yes | yes |
198| command reference | yes | no | n/a |
199
200=explore= is the listing without a query; =repo search= is the one with.
201=search= is both plus the title and body of every issue and merge
202request the caller can read, over FTS5; the web serves it at =/search=
203with a field in the rail, and an anonymous visitor gets the public rows
204from the same query. =issue list --search= and =mr list --search= narrow
205one repository. What someone types is quoted term by term, so an FTS5
206operator — =c++=, =AND=, a lone quote — is a word to match and never a
207syntax error. =repo grep= remains the per-repository file-contents
208search.
209
210About text renders as markdown or org-mode per the =about_format= it
211was stored with. The iOS =Profile= decoder lists its keys explicitly,
212so it ignores =about= and =links= rather than breaking on them.
213
214=help= lists the command registry. Bare it is an index, one line per
215command, sorted. With a prefix (=help mr=) it adds each command's
216argument syntax, which is the only place flags are written down;
217=gitbay <cmd> --help= asks the server for the same thing, and =--json=
218returns ={path, summary, usage}=. No web page renders it, and a native
219client has no use for one (krz/gitbay#57).
220
221* Accounts
222
223| capability | cli | web | ios |
224|-----------------------------+-----+-----+-----|
225| SSH keys: list, add, remove | yes | yes | yes |
226| PGP keys: list, add, remove | yes | yes | yes |
227| email add and verify | yes | yes | yes |
228| dashboard aggregate | yes | yes | yes |
229| notification inbox | yes | yes | no |
230| API token mint | yes | no | no |
231| account export bundle | yes | no | no |
232| profile set | yes | no | no |
233| request a login link | n/a | yes | no |
234
235Notifications land in an inbox row per recipient whether or not the
236instance sends mail, and mail is the second half when SMTP is
237configured. =notifications list= reads it, unread by default;
238=notifications read <id>... | --all= clears it; the dashboard and the
239web rail carry the unread count. =repo watch= adds you to a
240repository's notifications and =repo unwatch= mutes it, and a mute wins
241over owning the repository or having written the thread.
242
243A login link is requested from the login page by username or verified
244address, and arrives by mail: it works once and expires in fifteen
245minutes. The row is =n/a= for the CLI because a terminal with a
246registered key runs =web login=, which mints a link directly and needs
247no mail. It exists because an account with no SSH key had no way into
248the web at all (krz/gitbay#155). The page offers the form only when the
249instance has SMTP configured; there is no separate switch. The response
250never says whether the account exists.
251
252=profile set= carries description, website, about and links. It is not
253=SSHOnly= — nothing about a bio is a credential, and the JSON API runs
254it — but no surface has ever offered a form, so the =no= above is
255missing UI rather than a rule.
256
257* Organizations
258
259| capability | cli | web | ios |
260|---------------------------+-----+-----+-----|
261| read members and teams | yes | yes | yes |
262| members add, remove, role | yes | yes | yes |
263| teams create, delete | yes | yes | yes |
264| team members | yes | yes | yes |
265| team repo grants | yes | yes | yes |
266| create, rename, delete | yes | no | no |
267
268* Pagination
269
270=issue list=, =mr list=, =repo list=, and =feed= take =--limit <n>=
271and =--cursor <c>=. Cursors are opaque; each page carries the next
272one. Without the flags a list stays complete, so existing scripts are
273unchanged. The web pages the issue and merge request lists at fifty
274with the same cursors; iOS pages with them too.
275
276* SSH only, by design
277
278Build secrets, mirror configuration and tokens, custom domain claims,
279API token minting, deploy keys, account and instance administration. Deleting or transferring a repository is also
280CLI-only: both want a typed confirmation, not a button.
281
282These are the only rows where a =no= is intended. Everywhere else a
283=no= is work outstanding, and =n/a= means a surface cannot usefully
284carry the capability at all — see the archive note above.
.gitbay/wiki/Performance.org added +49
@@ -0,0 +1,49 @@
1#+title: Performance
2
3Results from a stress test of gitbay.org (2026-08-25): a server-side
4import of the Git project's own repository — 82,056 commits, 1,016
5refs, 167MB packed — followed by timed requests against every page
6type. The instance is a single 1 vCPU / 1GB VPS that was concurrently
7running the CI runner, the mirror worker, and the scheduler.
8
9* Summary
10
11The import took 41.5 seconds end to end. Every core page rendered in
12under a second against the full history; the heaviest operations —
13blame on a 7,000-line file and full-clone pack generation — cost what
14git itself costs, and nothing else. Load peaked at 0.42 and memory
15held near 190MB through the entire run. A full anonymous HTTPS clone
16of all 82,056 commits completed in 17 seconds and round-tripped with
17an identical commit count.
18
19* Numbers
20
21| operation | time |
22|------------------------------------------------+-------|
23| server-side import (=repo import=) | 41.5s |
24| repo tree + README render | 0.56s |
25| log page (50 commits, signature checks) | 0.68s |
26| per-file history (=?path=diff.c=) | 0.61s |
27| directory history (=?path=Documentation=) | 0.55s |
28| refs page (1,016 refs) | 0.61s |
29| blob of diff.c (7k lines, 3MB highlighted) | 1.57s |
30| blame on diff.c across the full history | 2.20s |
31| content grep | 0.70s |
32| archive tar.gz (12MB) | 2.46s |
33| signed-tag commit page (unknown-key path) | 0.29s |
34| shallow HTTPS clone | 3.8s |
35| full HTTPS clone (82,056 commits) | 17.2s |
36
37* Why it holds
38
39gitbay keeps no object database of its own: transports stream through
40=git upload-pack=, object reads go through =git cat-file --batch=, and
41blame, grep, and archives are the git binary doing what it already does
42well. The forge's own work — auth, policy, rendering — is milliseconds
43around that. Metadata lives in one SQLite file in WAL mode, which at
44this scale never appears in a profile.
45
46The practical ceiling on this hardware is concurrent pack generation:
47full clones of large repositories are CPU-bound in git itself (the 17s
48clone ran git at ~156% CPU). A busier instance would scale that with
49cores, not with changes to gitbay.
.gitbay/wiki/Quickstart.org added +8
@@ -0,0 +1,8 @@
1#+title: Quickstart
2
31. Install the CLI: =go install gitbay.org/gitbay/cmd/gitbay@latest=
42. =ssh git@gitbay.org whoami= — your key is your identity
53. =gitbay repo create you/hello= then push
64. =gitbay issue create --title "first"= from inside the clone
7
8Everything works from stock OpenSSH too: =ssh git@gitbay.org help=.
.gitbay/wiki/Roadmap.org added +233
@@ -0,0 +1,233 @@
1#+title: gitbay roadmap
2
3Status and direction as of 2026-08-24. Issue numbers reference this
4repository's tracker; this file is the narrative, the tracker is the
5truth.
6
7* Where things stand
8
9Everything in the original plan is built, tested end-to-end against real
10git/ssh/sshd/gpg, and running in production at gitbay.org: the SSH
11control plane (usable from bare OpenSSH, enforced by test), git over
12SSH/HTTPS/git://, signature verification with six states and epoch
13caching, protected branches and =require_signed_commits= (push-time and
14merge-time), issues, merge requests with four merge strategies, orgs
15with membership-derived access, rename/transfer, repo import, invite and
16open registration with SMTP verification, ACME TLS, the read-only and
17accounts web modes, the JSON API fronting the whole command registry,
18signed webhooks with retries and dead-lettering, restore-tested backups,
19the =gitbay= CLI, and docs. The instance hosts 65 repositories including
20this one, and its own development already runs through its issues and
21merge requests.
22
23What it is today: an excellent forge for its author and for CLI-native
24individuals. What it is not yet: a forge a GitHub-habituated *team*
25would stay on, or a project outsiders can easily run themselves.
26
27* Phase 1 — collaboration credibility [COMPLETE 2026-08-24]
28
29Goal: a second contributor works here for a week and misses nothing they
30would act on. All five shipped: deploy keys, commit statuses with
31require-checks gating, email notifications, inline review threads, and
32required approvals with CODEOWNERS and require-resolved.
33
34- [[https://gitbay.org/krz/gitbay/issues/22][#22]] deploy keys — smallest item, unblocks CI checkout; the scope
35 already exists in the policy layer
36- [[https://gitbay.org/krz/gitbay/issues/1][#1]] commit statuses API and MR check display
37- [[https://gitbay.org/krz/gitbay/issues/3][#3]] email notifications for issue/MR activity
38- [[https://gitbay.org/krz/gitbay/issues/2][#2]] inline review comments on MR diffs
39- [[https://gitbay.org/krz/gitbay/issues/19][#19]] required approvals and CODEOWNERS (builds on #1 and #2)
40
41* Phase 2 — a product, not a debug view [COMPLETE 2026-08-24; #30 activity graph follows on]
42
43Goal: the site looks and reads like something you would recommend.
44Mostly web-layer; descriptions and profiles already landed as the first
45step.
46
47- [[https://gitbay.org/krz/gitbay/issues/10][#10]] design revamp — closed 2026-08-24 after review (further design work is iteration under new issues)
48 — shipped 2026-08-24, open pending visual review
49- [[https://gitbay.org/krz/gitbay/issues/6][#6]] cross-references (#N) and @mentions — done 2026-08-24
50 (rendering-side; backlinks and mention notifications later)
51- [[https://gitbay.org/krz/gitbay/issues/23][#23]] archived repos and topics — done 2026-08-24 (topic
52 filtering rides along with search, #7)
53- [[https://gitbay.org/krz/gitbay/issues/24][#24]] blame view — done 2026-08-24
54- [[https://gitbay.org/krz/gitbay/issues/7][#7]] search — done 2026-08-24 (repo search by name/desc/topic,
55 per-repo git grep on web+SSH; cross-repo indexer only if ever needed)
56- [[https://gitbay.org/krz/gitbay/issues/20][#20]] milestones and issue templates — done 2026-08-24
57- [[https://gitbay.org/krz/gitbay/issues/30][#30]] activity graph on owner pages — done 2026-08-25 (commit_activity
58 by verified author email, dedup by sha; 53-week grid on user/org pages;
59 2,643 commits backfilled on gitbay.org)
60- [[https://gitbay.org/krz/gitbay/issues/31][#31]] issue actions from commit messages — done 2026-08-24
61 (closes/fixes/resolves #N closes on landing; bare #N leaves a comment)
62
63* Phase 3 — other people's forges [COMPLETE 2026-08-24]
64
65Goal: someone who is not the author runs an instance and moves their
66work to it.
67
68- [[https://gitbay.org/krz/gitbay/issues/26][#26]] release engineering — done 2026-08-24 except artifact
69 hosting, which waits on #8 (go-install vanity live, release.sh,
70 CHANGELOG, Homebrew formula in krz/homebrew-tap)
71 imports, Homebrew/deb — the adoption gate for everything below
72- [[https://gitbay.org/krz/gitbay/issues/27][#27]] repository maintenance — done 2026-08-24 (admin gc/stats, weekly gitbay-gc.timer)
73- [[https://gitbay.org/krz/gitbay/issues/8][#8]] releases — done 2026-08-24 (notes + assets; v0.1.0 binaries hosted)
74- [[https://gitbay.org/krz/gitbay/issues/17][#17]] issue/PR history import from GitHub — done 2026-08-24
75- [[https://gitbay.org/krz/gitbay/issues/18][#18]] push/pull mirroring — done 2026-08-24 (worker sync, read-only pull mirrors)
76- [[https://gitbay.org/krz/gitbay/issues/29][#29]] account migration — done 2026-08-24 (bundle export/replay + client-side git mirror; no lock-in,
77 ever; the export bundle doubles as a user-level backup)
78- [[https://gitbay.org/krz/gitbay/issues/14][#14]] audit logging and multi-user hardening — done 2026-08-24 (quotas and key-expiry warnings ride with #28)
79- [[https://gitbay.org/krz/gitbay/issues/9][#9]] web signup for open/invite instances — done 2026-08-24
80
81* Phase 4 — reach
82
83Bigger bets, each valuable independently; order by appetite.
84
85- [[https://gitbay.org/krz/gitbay/issues/13][#13]] CI/CD — done 2026-08-25 (.gitbay/ci.yml jobs, gitbay-runner over
86 SSH on bay1, statuses feed require-checks; the forge never executes
87 repository content itself)
88- [[https://gitbay.org/krz/gitbay/issues/16][#16]] Git LFS
89- [[https://gitbay.org/krz/gitbay/issues/15][#15]] static pages — done 2026-08-25 (public repos' =pages= branches on
90 <owner>.<pages domain>, separate origin; gitbay.org deployment awaits
91 the domain)
92- [[https://gitbay.org/krz/gitbay/issues/11][#11]] iOS app (hutch-based) and [[https://gitbay.org/krz/gitbay/issues/12][#12]] Android
93- [[https://gitbay.org/krz/gitbay/issues/21][#21]] teams within orgs — done 2026-08-25 (members-role scoping + per-repo team grants)
94- [[https://gitbay.org/krz/gitbay/issues/25][#25]] wikis — done 2026-08-25 (push-edited .wiki companions; krz/gitbay has one)
95
96Done for the wiki half (2026-08-25): the docs now live in this wiki,
97dogfooding #25. When #15 (pages) lands they can graduate to a published
98site.
99
100* Phase 5 — the web grows up [COMPLETE 2026-08-26; v0.5.0 and v1.0.0, [[https://gitbay.org/krz/gitbay/issues/35][#35]]]
101
102Two goals, one structure. The design needs sustained iteration, and the
103web needs enough capability that nobody calls it useless — without
104diluting CLI-first.
105
106** Identity, settled
107
108The CLI is the complete interface: every capability exists over SSH,
109and web writes call the same control handlers. The web is the reading,
110reviewing, and responding surface — its bar is that a maintainer can
111complete the triage/review/merge loop from a browser. Deliberately
112CLI-only forever: secrets, mirror tokens, domain claims, and anything
113else whose input is a credential (stdin discipline). No-JS pages remain
114the baseline.
115
116** Current web write surface (audit 2026-08-25)
117
118repo create, file edit, issue create/comment/edit, MR comment/edit,
119pin. Everything else is read-only.
120
121** Foundations (before page work)
122
123- Navigation IA: ten flat repo tabs wrap on mobile. Regroup — code
124 (files/log/refs), work (issues/MRs/builds), publish
125 (releases/wiki) — search and archive demoted to compact affordances.
126- Type scale and spacing rhythm pass; consistent card/list grammar.
127- Accessibility baseline: landmarks, focus states, contrast audit,
128 skip link; keyboard-only walk of every page.
129- Diff renderer: syntax-highlighted diffs, per-file sections with
130 stats and collapse — shared by commit and MR pages.
131- Empty states everywhere a list can be empty.
132
133** Page workstreams (design + parity land together)
134
135Each ends with a screenshot checkpoint (both schemes, three widths)
136before merge.
137
1381. MR page: timeline/diff layout, review actions (approve/request
139 changes), thread resolve, merge button with gate status, retarget;
140 MR create from the web (branch picker).
1412. Issues: close/reopen, labels, assignees, milestone from the web;
142 list filtering that matches the CLI's.
1433. Repo home: header with latest-commit line and clone box that
144 doesn't fight the tree; file table polish.
1454. Dashboard: review requests, assigned work, recent activity feed —
146 a reason to set it as a browser home page.
1475. Commit + log: statuses inline, signature chips tightened, log
148 filtering UI for the ?path= history.
1496. Owner/org: team visibility, member management for org admins.
1507. Settings surface (repo admins): description, website, topics,
151 branch protection and merge gates, visibility, archive — the
152 safe subset of repo settings; plus SSH key management for accounts
153 (add/remove keys from an authenticated session).
1548. Releases/builds: create and edit releases, retrigger builds.
155
156** Outcome
157
158All eight page workstreams landed, plus the foundations. The web is now
159the reading, reviewing and responding surface it was scoped to be: a
160maintainer completes the triage/review/merge loop, manages their own
161keys and addresses, and runs an organization from a browser. The diff
162renderer (foldable per-file sections, line-number gutters, syntax
163highlighting per hunk per side) is shared by the commit and merge
164request pages. Repository homes state their own facts — commits,
165branches, tags, license, latest release, build status, language census,
166contributors resolved to accounts by verified email.
167
168Still SSH-only by design, and listed as such on [[Parity]]: token
169minting, account export, secrets, mirror tokens, domain claims,
170repository delete and transfer, organization create/rename/delete.
171
172** Guardrails
173
174- PARITY page in this wiki: a maintained matrix of capability x
175 surface (SSH/CLI/web/API), updated in the MR that changes it.
176- Rule for new features: lands over SSH first; if it belongs to the
177 triage/review/respond loop it lands on the web in the same MR.
178- The view-only mode guarantee stays structural: accounts-mode routes
179 never registered there.
180
181* Phase S — security (cross-cutting)
182
183Not a sequential phase: items land alongside whatever phase is active,
184and the whole set gates flipping gitbay.org to open registration.
185
186- [[https://gitbay.org/krz/gitbay/issues/14][#14]] audit logging, rate limiting, quotas, user disable — the
187 multi-user half
188- [[https://gitbay.org/krz/gitbay/issues/28][#28]] hardening umbrella — concrete items done 2026-08-24
189 (umbrella stays open for ongoing work). Both layers landed:
190 - software: fuzz targets for every attacker-facing parser (found and
191 fixed a decodeArmor slice bug), CSP + security headers, govulncheck
192 in =deploy/audit.sh= (fixed circl GO-2026-4550), token comparison
193 audit (hash-lookup, no Go-level compare), [[Threat-Model]],
194 optional minisign over release manifests
195 - host: systemd sandbox (=SystemCallFilter=@system-service=,
196 =PrivateDevices=, =LockPersonality=, =MemoryDenyWriteExecute=, …),
197 unattended-upgrades, fail2ban + =MaxStartups=/=MaxAuthTries= on the
198 admin sshd, hourly disk/service/cert monitoring; DB file modes 0750,
199 litestream noted for continuous replication. Applied to bay1.
200
201Already true and worth preserving (the threat model will write these
202down): the forge never executes repository content; no server signing
203key; repo-authored HTML never renders on the forge origin; tokens and
204sessions stored as hashes only; SSRF guards at registration and dial
205time; private repositories indistinguishable from nonexistent.
206
207* Explicitly not planned
208
209Recorded so their absence reads as a decision, not an oversight:
210
211- container/package registry — scope creep away from "forge"; external
212 registries integrate via CI
213- email patch flow — revisit only if sourcehut-style demand appears
214- federation (ForgeFed) and Postgres — no current need at this scale
215
216* Decisions
217
218- **gitbay.org will eventually be open to all.** (Decided 2026-08-24.)
219 Sequencing consequence: before flipping =registration = "open"=, the
220 instance needs #14 (audit log, rate limiting, quotas), #9 (web
221 signup), an SMTP relay configured for verification mail, and enough
222 of Phase 1 that new users get a credible product. Interim step:
223 invite mode for early collaborators as soon as [mail] is configured.
224- **Versioning**: semver, starting at v0.1.0 on the current state.
225 0.x signals moving surfaces; =protocol_version= increments only on
226 breaking envelope/command changes and is otherwise decoupled from
227 release numbers. v1.0.0 when Phase 1 and #26 land. Tags are
228 annotated and signed.
229- **Second contributors**: invite mode is the on-ramp (their keys and
230 verified emails make signed-main enforceable for them too). A
231 CONTRIBUTING file states the workflow: fork on gitbay.org, MR with
232 signed commits, =go test ./...= green, review required once #19
233 exists. No CLA — 0BSD needs none; sign-off optional.
.gitbay/wiki/Stacked-MRs.org added +130
@@ -0,0 +1,130 @@
1#+title: Stacked merge requests
2
3Two or more merge requests where each targets the source branch of the
4one below it, down to =main=. Review and merge them one layer at a time;
5the forge moves what is above onto =main= as each layer lands.
6
7* What a stack is
8
9#+begin_example
10 main ─── A (feat-a) !159 feat-a → main
11 └── B (feat-b) !160 feat-b → feat-a stacked on !159
12 └── C (feat-c) !161 feat-c → feat-b stacked on !160
13#+end_example
14
15The rule is one sentence: =B= is stacked on =A= when =B='s target branch
16is =A='s source branch, both are open, and both are in the same
17repository. Nothing is stored and no flag exists. The stack is a fact
18about branches that the forge reads whenever it shows a merge request,
19so it is never out of date and cannot be forgotten to be set.
20
21* Why
22
23- Keep working on a change that depends on one still in review, instead
24 of waiting for it to merge or carrying both in one branch.
25- Each merge request holds one reviewable change. The diff of =B= is
26 =B='s commits only, not =A='s underneath it.
27- Reviews and checks sit on the layer they were made on and stay there
28 when the layer below merges.
29
30* The workflow is branches
31
32There is no stack command. Branch from the branch below, push, and open
33the merge request against it:
34
35#+begin_src sh
36git checkout -b feat-a main && ...commit... && git push -u origin feat-a
37gitbay mr create --source feat-a --target main --title "A"
38git checkout -b feat-b feat-a && ...commit... && git push -u origin feat-b
39gitbay mr create --source feat-b --target feat-a --title "B"
40#+end_src
41
42The second =mr create= answers with a line the first did not:
43
44#+begin_example
45created krz/gitbay!160 (feat-b -> feat-a)
46stacked on !159 A
47#+end_example
48
49=mr show= carries =stacked_on= (the merge request below) and =stacked=
50(the ones above); =mr list= rows carry =stacked_on=; the merge request
51page says "Stacked on !159" in the header and "Builds on this: !161"
52below it.
53
54Changing a lower layer is a rebase you do yourself. Amend =feat-a=,
55then =git rebase feat-a= on =feat-b= and each layer above, and
56force-push them. A force-push stales the reviews on that layer, the
57same as on any merge request. The server never rewrites your commits:
58it holds no signing key, and a rebase it performed would land commits
59nobody signed.
60
61* Merging
62
63Merge from the bottom. When =A= merges, every merge request stacked on
64it is retargeted onto what =A= merged into, with a system comment:
65
66#+begin_example
67retargeted from feat-a to main: !159 merged
68#+end_example
69
70Reviews on the retargeted merge request are kept. After a fast-forward
71or a merge commit, =A='s commits are on =main=, so =B='s diff against
72=main= is the diff its reviewers approved; there is nothing to stale.
73
74That is also why a stack constrains the strategy. A squash or rebase
75merge of =A= puts different commits on =main= than the ones =B= builds
76on, and =B='s diff would carry =A='s changes a second time. So while
77anything is stacked on a merge request, =--strategy squash= and
78=--strategy rebase= are refused:
79
80#+begin_example
81!159 is stacked on by !160; a squash merge rewrites the commits they
82build on. Merge with --strategy ff or merge, or merge the stack into
83feat-a first
84#+end_example
85
86The second option is real: merging =B= into =feat-a= while =A= is open
87is allowed and collapses =B= into =A=, whose head moves as on any push
88to its branch. Closing =A= without merging leaves the stack alone; its
89branch still exists and =B= still targets it.
90
91Merge gates apply per layer as on any merge request: required
92approvals, resolved threads, green checks, and =require_signed_commits=,
93which under a stack already forces fast-forward.
94
95* Where it works
96
97- CLI and bare SSH: everything above.
98- Web: the header shows the stack both ways. Creating a merge request
99 against a branch that is another's source works from the form; the
100 stack appears once it exists.
101- iOS: renders the retarget comment; no stack view yet.
102- Same repository only. A fork's branch is not something another merge
103 request can target, so a stack cannot cross a fork.
104
105See [[Parity][Parity]] for the row.
106
107* A stack that merged
108
109The six merge requests that shipped v1.6.0 were the first stack merged
110on gitbay.org, one commit each, on 2026-09-02:
111
112| MR | source | target |
113|------+-----------------+-----------------|
114| !159 | stack-1-reaper | main |
115| !160 | stack-2-runners | stack-1-reaper |
116| !161 | stack-3-healthz | stack-2-runners |
117| !162 | stack-4-monitor | stack-3-healthz |
118| !163 | stack-5-lfs | stack-4-monitor |
119| !164 | stack-6-verify | stack-5-lfs |
120
121Each was merged with =mr merge --strategy ff= in that order. After each
122one, the next reported =target_ref: main= and no =stacked_on=, with the
123comment naming the merge — =retargeted from stack-1-reaper to main:
124!159 merged= on !160, and so on up to !164. No =mr retarget= was typed.
125=main= ended with the six commits in order on top of the commit that
126had added stacking.
127
128One thing the run exposed: each fast-forward queued the commit's CI jobs
129again on =main=, although the same commit had just passed them on its
130branch. That is [[https://gitbay.org/krz/gitbay/issues/90][#90]].
.gitbay/wiki/Threat-Model.org added +151
@@ -0,0 +1,151 @@
1#+title: gitbay threat model
2
3What the forge trusts, what it refuses to do, and where the boundaries
4are. This is the reference for security review; it complements the audit
5log and hardening notes in [[Admin]].
6
7* What gitbay never does
8
9- *Execute repository content.* Git object contents are never run. Hooks
10 are gitbay's own binary, invoked by git; they compute facts and ask the
11 daemon over a unix socket. Repo files are only ever read.
12- *Hold a signing key.* There is no server-side signing key. "Verified"
13 means a signature made by a key the *user* registered — the server
14 never vouches for a commit it did not receive already signed. Merge
15 commits the server creates are honestly =unsigned=.
16- *Serve repository HTML on its own origin as active content.* Raw file
17 serving is =text/plain= with =nosniff=. Rendered markdown/org is
18 sanitized (bluemonday) and served under a CSP that forbids scripts.
19- *Put secrets in argv, URLs, or logs.* Import and mirror credentials,
20 registration invites, and API tokens travel on stdin or in request
21 bodies, never as command arguments (visible in =/proc=) or query
22 strings. Tokens are stored only as SHA-256 hashes.
23- *Confirm the existence of private repositories.* Every surface answers
24 "not found" identically for a private repo and a nonexistent one — web
25 pages, git transport, control commands, release asset downloads.
26
27* Trust boundaries
28
29- *SSH public key = identity.* The SSH username is ignored; the presented
30 key's fingerprint resolves to an account. Key uniqueness is global.
31- *Per-instance trust.* Email verification and key registration are local
32 to an instance and never transfer. Account migration re-registers keys
33 and re-verifies emails on the target by design.
34- *The control plane is one authenticated channel* (SSH), fully usable
35 from stock OpenSSH. The JSON API fronts the same command registry with
36 bearer tokens minted only over SSH; git transport never runs over it.
37- *Anonymous surfaces* — HTTPS clone of public repos, =git://= where
38 enabled, the read-only web UI — carry no credentials and expose only
39 public data. HTTP push is refused via a pkt-line =ERR=, never a 401.
40
41* Attacker-controlled parsers
42
43Every parser that eats bytes from a pusher, a key registrant, or an
44anonymous client has a fuzz target and must never panic:
45
46- =internal/protocol= — the SSH command tokenizer (fuzzed against argv
47 round-tripping).
48- =internal/gitd= — the =git://= pkt-line reader.
49- =internal/sig= — the commit parser, the SSHSIG armor decoder and blob
50 parser, and the OpenPGP armored-key reader.
51
52Run =deploy/audit.sh= to exercise them plus =go vet= and =govulncheck=.
53
54* Secret handling
55
56Tokens (web sessions, login links, email verification, API bearer tokens,
57deploy/CI) are random 256-bit values. Only their SHA-256 hash is stored,
58and verification is a database index lookup on that hash — the secret
59itself is never compared in Go, so there is no timing oracle to exploit.
60Webhook payloads are signed outbound with HMAC-SHA256; the forge verifies
61no inbound HMAC.
62
63* Network-facing request forgery
64
65Anything that makes the *server* open an outbound connection to a
66user-supplied address — webhook delivery, GitHub-history import
67=--api-base=, mirror remotes — passes the same SSRF guard: the scheme
68must be http/https and, unless =webhooks.allow_local= is set, the
69resolved address must not be loopback, private, or link-local. The
70webhook dialer re-checks at connect time so a DNS answer that changes
71after validation still cannot reach private space. Redirects are never
72followed.
73
74* Rendering pushed markup
75
76Rendered markup is attacker-controlled: a README, a wiki page and a
77profile's about text are all whatever someone pushed or typed. The risk
78is not only what the output contains but what the *parser* is willing to
79go and fetch — the filesystem counterpart of the SSRF guard above.
80
81Org is rendered by go-org, whose default configuration resolves
82=#+INCLUDE:= and =#+SETUPFILE:= targets with =os.ReadFile=. Both are
83refused outright (=orgConfig()= in =internal/httpd=): the file is never
84opened and the keyword stays the inert text it already was, so the rest
85of the document renders normally. There is no safe subset to allow
86instead — an absolute path skips go-org's relative-path join, a relative
87one resolves against the daemon's working directory, and the content
88came from a git object rather than a checkout, so there is no directory
89to scope a read to. Markdown is goldmark, which has no include
90mechanism. go-org's parse warnings are discarded rather than logged, so
91pushed content cannot write to the server's log.
92
93Org output is then sanitized (bluemonday UGC policy) because go-org
94passes raw HTML through — export blocks and inline export snippets —
95while goldmark drops it and needs no pass. The policy admits chroma's
96short token classes and nothing else.
97
98* Web responses
99
100Every response carries =Content-Security-Policy= (no scripts, no plugins,
101no embedding; inline styles allowed for chroma and label chips; images
102from any origin so external README images render), =X-Frame-Options:
103DENY=, =X-Content-Type-Options: nosniff=, =Referrer-Policy: no-referrer=,
104and =Strict-Transport-Security= when TLS is on. The UI needs no
105JavaScript, so =script-src 'none'= costs nothing.
106
107* The CI runner
108
109=gitbay-runner= is the one component that executes repository content.
110gitbayd never does: it reads =.gitbay/ci.yml= and queues a build, and a
111runner, polling over SSH, clones the commit and runs its steps.
112
113- *What the runner holds.* A key added with =keys add --scope runner=,
114 which the dispatcher confines to =runner next=, =runner log= and
115 =runner done= and to read-only git. A step that reads the key off the
116 disk gets exactly that: it cannot administer the instance, push, or
117 read a repository the runner's account cannot. An admin key still
118 works for the runner protocol so an operator can rotate at their own
119 pace; a runner host should not hold one.
120- *What a build sees.* The commit, the =GITBAY_*= variables and the
121 repository's secrets — unless the head came from another repository.
122 A merge request from a fork is built in the target as untrusted, with
123 no secrets, so a stranger's branch cannot read the target's deploy
124 credentials.
125- *Where it runs.* Steps run as the runner's own user on the runner
126 host, with no container; the systemd drop-in adds =NoNewPrivileges=,
127 =ProtectSystem=full= and the kernel and cgroup protections. =-repos=
128 limits a runner to named repositories, which is the control that
129 matters on an open instance: without it a runner builds whatever
130 anyone pushes.
131
132Anything a step can do as the runner's user, a pushed =ci.yml= can do.
133Treat the runner host as executing untrusted code: keep it off the
134daemon's host where the database lives, or scope it to repositories
135whose writers you trust.
136
137* Residual risks, accepted
138
139- External images in rendered READMEs and profile about text load from
140 their origin (no image proxy), which the author can use as a tracking
141 pixel against a viewer. A profile is the wider surface of the two: it
142 is linked from every commit and issue its owner touches. Documented;
143 proxying is future work.
144- Backups are consistent per the DB-snapshot-first ordering but are not a
145 single atomic snapshot; a few orphaned git objects are possible and
146 harmless (see [[Admin]]).
147- A global signature-verification epoch over-invalidates the cache on any
148 trust-input change. Correct, not a leak; a performance tradeoff.
149- Build steps run as the runner's user with no container. Isolation is
150 the key scope, the sandboxing drop-in and =-repos=; containers are
151 future work.
.gitbay/wiki/Users.org added +480
@@ -0,0 +1,480 @@
1#+title: gitbay user guide
2
3Everything here works from stock OpenSSH — replace =gitbay= with
4=ssh git@<host>= in any command and it behaves identically. The CLI adds
5convenience (instance profiles, repo inference, =$EDITOR=), nothing more.
6=ssh git@<host> help= lists every command the server knows.
7
8* Installing the CLI
9
10#+begin_src sh
11go install gitbay.org/gitbay/cmd/gitbay@latest # any platform with Go
12
13brew tap krz/tap https://gitbay.org/krz/homebrew-tap.git
14brew install krz/tap/gitbay # Homebrew (macOS/Linux)
15#+end_src
16
17Or build from source: =go build ./cmd/gitbay= in a clone of
18=https://gitbay.org/krz/gitbay.git=.
19
20* Getting an account
21
22How you join depends on the instance's registration mode. On instances
23with web accounts enabled, =/register= offers the same signup as a
24browser form (paste your SSH public key); everything below works from
25the terminal alone:
26
27- closed :: an admin creates your account on the host and registers your
28 first SSH key. Nothing for you to do but hand over your public key.
29- invite :: you receive a single-use code by email. With the SSH key you
30 want to use:
31 #+begin_src sh
32 ssh git@<host> register --username you --invite <code>
33 #+end_src
34 Your account is active immediately; the invited address is your
35 verified email.
36- open ::
37 #+begin_src sh
38 ssh git@<host> register --username you --email you@example.org
39 #+end_src
40 A verification code arrives by mail. Until you run
41 =ssh git@<host> email verify <code>=, the account is pending: you can
42 run =whoami= and the email commands, and nothing else — no git, no
43 repos.
44
45* SSH keys
46
47Your key is your identity; there are no passwords anywhere. The SSH
48username is always =git= — the key alone determines who you are.
49
50#+begin_src sh
51gitbay auth keys list
52gitbay auth keys add --scope git < ~/.ssh/ci_key.pub # key on stdin
53gitbay auth keys remove SHA256:...
54#+end_src
55
56Scopes: =full= (default; git plus every control command), =git= (git
57transport only — right for automation keys, which then cannot touch
58issues, settings, or your account), or =runner= (the CI runner's
59protocol plus read-only git, for the key a =gitbay-runner= host holds;
60see [[Admin]]).
61
62A key belongs to exactly one account instance-wide. Registering a key
63someone else already holds is refused without telling you whose it is.
64
65* Verified commits
66
67The commit badge is driven by the *author* email and the signing key:
68=verified= means the signature is valid, the key is registered to an
69account, and the author email is a verified address on that account.
70
71For OpenPGP signing (git's default):
72#+begin_src sh
73gpg --armor --export you@example.org | gitbay auth pgp add
74#+end_src
75
76For SSH signing (=git config gpg.format ssh=): sign with any key
77registered on your account; your verified addresses act as the principal
78set. No separate registration step.
79
80Add and verify additional addresses with =email add <address>= /
81=email verify <code>= (requires the instance to have SMTP; otherwise an
82admin can assert an address for you).
83
84The states you will see, in decreasing order of trust: =verified=,
85=signed_unknown_key= (valid signature, key not registered here — register
86it and history upgrades retroactively), =signed_email_mismatch= (real
87key, author line claims someone else), =signed_key_expired= /
88=signed_key_revoked=, =bad_signature=, =unsigned=. Server-created commits
89(web edits, merge commits) are always =unsigned= — the server holds no
90signing key on principle.
91
92* Profiles
93
94A profile is what =/{owner}= shows: a one-line description, a website, a
95set of links, long-form about text, the repositories you can see, org
96membership, and a year of activity. Users and orgs have the same fields.
97Repository rows carry the listing metadata too — topics, license,
98default branch, last commit — so a client renders a profile listing the
99way =/{owner}= does. The web dispatches this command rather than
100assembling the page itself.
101
102#+begin_src sh
103gitbay profile show # your own
104gitbay profile show alice
105gitbay profile set --description "builds small tools" --website https://alice.example
106#+end_src
107
108About text is markdown by default, or org-mode. It takes inline text or
109stdin, so it can live in a file you keep:
110
111#+begin_src sh
112gitbay profile set --about "I maintain a few small tools."
113gitbay profile set --file - --about-format org < about.org
114#+end_src
115
116Up to five links, each =label|url= or a bare url, http(s) only. Passing
117=--link= replaces the whole set; a single empty one clears it:
118
119#+begin_src sh
120gitbay profile set --link "Mastodon|https://fosstodon.example/@alice" \
121 --link https://alice.example/now
122gitbay profile set --link "" # clear
123#+end_src
124
125Every field follows the same rule as the rest of the CLI: a flag you
126leave out is untouched, and ='' clears the one you name. Org profiles
127work the same way and need org admin:
128
129#+begin_src sh
130gitbay org profile krz --description "software and experiments" --about-format org --file - < krz.org
131gitbay org profile krz # no flags shows it
132#+end_src
133
134The web renders profiles but has no form for editing one, so the CLI is
135the only interface today. =profile set= is not =SSHOnly=, so the JSON API
136runs it like any other write command.
137
138* Repositories
139
140#+begin_src sh
141gitbay repo create you/project [--private]
142gitbay repo clone you/project
143gitbay repo list
144gitbay repo show you/project
145gitbay repo log you/project --limit 20 # commits with signature states
146gitbay repo fork other/project [--name mine]
147gitbay repo delete you/project --yes
148#+end_src
149
150Pushing is SSH-only. Public repositories are anonymously readable over
151HTTPS (and =git://= where enabled); private repositories exist only over
152SSH and answer "not found" to everyone without access.
153
154Access and settings (owner or =admin= grant):
155#+begin_src sh
156gitbay repo access grant you/project alice write # read | write | admin
157gitbay repo access revoke you/project alice
158gitbay repo settings protect you/project main # no force-push, no delete
159gitbay repo settings require-signed you/project on # every commit must verify
160gitbay repo settings git-daemon you/project on # expose over git://
161gitbay repo topics add you/project cli forge # free-form tags, shown on the web
162gitbay repo search forge # find repos by name/description/topic
163gitbay repo grep you/project "some string" # literal git grep over the default branch
164gitbay repo pin you/project # pin to your web dashboard
165gitbay repo unpin you/project
166gitbay repo archive you/project # read-only: pushes and issue/MR
167gitbay repo unarchive you/project # writes refused, browsing intact
168#+end_src
169
170Import from another forge — git data first, then optionally the GitHub
171issue and PR history (issues keep state/labels/comments; PRs land as
172closed or merged MRs with their discussion; originals are attributed
173inline since foreign authors have no local account; re-running resumes
174where it stopped):
175#+begin_src sh
176gitbay repo import you/mirror --from https://github.com/you/repo.git \
177 [--private] [--token-stdin] # token on stdin, never in the URL
178gitbay repo import-issues you/mirror --from you/repo --token-stdin
179#+end_src
180
181Moving between gitbay instances (no lock-in): run on the TARGET, with
182your key registered on both sides. Profile, repos with settings,
183issues, MRs, and comments replay with attribution; git data mirrors
184client-side through your own key. Keys never transfer and emails
185arrive unverified — trust is per-instance. Re-running resumes.
186Push-blocking policies (require-signed, protected branches) are
187deferred and printed for you to re-apply after the data lands.
188=gitbay auth export= alone doubles as a user-level backup.
189
190#+begin_src sh
191gitbay migrate --from old-instance.example [--from-port 22]
192#+end_src
193
194Mirroring keeps a foreign remote in sync during a gradual migration
195(repo admin; https remotes; the token is stored server-side for the
196recurring sync and never echoed back):
197
198#+begin_src sh
199gitbay repo mirror add you/project https://github.com/you/project.git \
200 --direction push --token-stdin # propagate after every local push
201gitbay repo mirror add you/copy https://github.com/them/theirs.git \
202 --direction pull # follow upstream; local pushes refused
203gitbay repo mirror list # sync status and last error, per mirror
204gitbay repo mirror sync / remove <id>
205#+end_src
206
207Markdown and org files render on the web when opened, the way a README
208does on the repository page, with relative links resolved against the
209file's directory; =source= in the file's action bar (or =?view=source=)
210shows the text instead. Other files show the text with highlighting.
211
212* Organizations
213
214Orgs share the owner namespace with users and own repositories at
215=org/repo=. By default members get write on all org repos; org admins
216get repo admin, create repos under the org, and manage membership.
217
218#+begin_src sh
219gitbay org create krz
220gitbay org members add krz alice [--role admin]
221gitbay org show krz
222gitbay org rename krz newname # clone URLs change
223gitbay org delete krz --yes # only when it owns no repositories
224#+end_src
225
226Large orgs scope access with teams: set what plain membership implies,
227then grant per-repo roles through named teams (org admins always keep
228admin; the default =write= keeps the simple model):
229
230#+begin_src sh
231gitbay org settings members-role krz none # write | read | none
232gitbay org team create krz core-devs
233gitbay org team add krz core-devs alice bob # org members only
234gitbay org team grant krz core-devs krz/gitbay write
235gitbay org team show krz core-devs # members + grants
236gitbay org team revoke / remove / delete ...
237#+end_src
238
239* Issues
240
241Anyone who can read a repository can file and comment. Closing/reopening
242is for the author or anyone with write; labels and assignees need write.
243
244#+begin_src sh
245gitbay issue create --title "it breaks" [--body "..." | --file -]
246gitbay issue list [--state open|closed|all]
247gitbay issue show 4
248gitbay issue comment 4 --message "same here"
249gitbay issue edit 4 --title "better title" [--body|--file -] # author or write
250gitbay issue close 4 / reopen 4
251gitbay issue label 4 --add bug --remove wontfix
252gitbay issue assign 4 --add alice
253gitbay issue milestone 4 v1.0 # or "none" to clear
254#+end_src
255
256Inside a clone, the repository is inferred from the =origin= remote —
257that is why no =owner/name= appears above. Anywhere else, pass it as the
258first argument. Long text: =--body= inline, =--file -= from stdin, or
259neither on a terminal and =$EDITOR= opens.
260
261Wikis are companion repositories edited by push — no separate storage,
262no web editor, the same rendering pipeline as READMEs (markdown and
263org). Access mirrors the parent repo (read to view, write to push);
264the companion is created on your first push and follows the repo
265through transfer and delete:
266
267#+begin_src sh
268git clone ssh://git@<host>/you/project.wiki.git
269# add Home.md (or .org), more pages, images; relative links between
270# pages just work on the web at /you/project/wiki
271git push
272#+end_src
273
274Releases anchor notes and binary assets to a pushed tag (write access;
275assets stream over SSH, capped by the instance's =max_asset_bytes=):
276
277#+begin_src sh
278gitbay release create v1.0 --title "First light" [--notes|--file -|$EDITOR]
279gitbay release asset add v1.0 tool-linux-amd64 < dist/tool-linux-amd64
280gitbay release asset get v1.0 tool-linux-amd64 > tool # or the web download link
281gitbay release list / show v1.0 / delete v1.0 --yes
282#+end_src
283
284The web shows them under the repository's =releases= tab with rendered
285notes, sha256 sums, and download links.
286
287Commit messages act on issues when the commits land on the default
288branch (direct push or MR merge): =closes/fixes/resolves #4= closes the
289issue with a linking comment, and a bare =#4= leaves a reference
290comment. Each issue/commit pair acts once, ever. Same repository only.
291
292Milestones group issues and MRs toward a release (write access to
293manage, attach with =issue milestone= / =mr milestone=; progress shows
294on the web at =/owner/name/milestones=):
295
296#+begin_src sh
297gitbay milestone create v1.0 --description "first release" --due 2027-01-01
298gitbay milestone list [--state open|closed|all]
299gitbay milestone close v1.0 / reopen v1.0
300#+end_src
301
302Issue templates: commit =.gitbay/issue-template.md= (and optional
303=issue-template-<name>.md= variants) to the default branch. =gitbay
304issue create= prefills =$EDITOR= with the default template, the web
305form prefills its textarea, and =gitbay issue templates= lists them.
306
307Lists narrow the same way on every surface: =issue list --label bug
308--assignee bob --author alice --milestone v1= (or =--milestone none=),
309=mr list --author bob --milestone v1=; the web's issue and merge request
310lists take the same names as query parameters, and each active filter
311shows with a link that drops it.
312
313Labels take a colour: =gitbay label set bug --color cf222e=; =label
314list= shows each with its colour and how many issues carry it, and
315=label remove= takes one off every issue. =issue label --add= still
316creates a colourless label on the fly.
317
318* Merge requests
319
320#+begin_src sh
321gitbay mr create --source feature --target main --title "add thing"
322gitbay mr create other/upstream --source you/fork:feature --target main --title "..."
323gitbay mr list / show 4 / diff 4
324gitbay mr checkout 4 # local branch mr/4 from the MR head
325gitbay mr review 4 --approve # or --request-changes / --comment
326gitbay mr merge 4 [--strategy ff|merge|squash|rebase]
327gitbay mr close 4
328#+end_src
329
330Semantics worth knowing:
331
332- the MR head lives in the *target* repository as
333 =refs/merge-requests/N/head= (fetchable by any reader), so an MR
334 survives deletion of its source branch or fork.
335- force-pushing the source updates the MR and marks existing reviews
336 stale.
337- default strategy: fast-forward when possible, else a merge commit.
338 Squash makes one commit authored by the MR author, committed by the
339 merger. Rebase replays a linear range preserving authors; it refuses
340 ranges containing merge commits, and when fast-forward is possible it
341 *is* one (original commits and signatures land untouched).
342- on =require_signed_commits= branches only fast-forwards of fully
343 verified commits merge; everything server-created is refused with
344 instructions to rebase locally.
345
346Repo admins can gate merges (=repo settings ...=): =require-approvals
347<n>= (fresh, non-author approvals; each reviewer's latest review is
348their stance, and a fresh request-changes blocks), =require-resolved=
349(no open review threads), =require-checks= (all statuses green), and
350=require-codeowners= (an approval from an owner of every owned changed
351file). Owners come from a =CODEOWNERS= file on the target branch, root
352or =.gitbay/=, gitignore-style patterns, last match wins. The toggle is
353the opt-in, so a repository can carry the file as documentation of who
354to ask without it gating merges; with it on and no file on the target
355branch, the merge is refused and says so. It does not wait on
356=require-approvals=.
357
358A merge request whose target is another open merge request's source
359branch is stacked on it: =mr create= says so, =mr show= carries
360=stacked_on= and =stacked=, and merging the lower one retargets the
361upper onto =main= with its reviews kept. See [[Stacked-MRs][Stacked
362merge requests]].
363
364Review threads anchor to diff lines:
365
366#+begin_src sh
367gitbay mr diff-comment 4 --path main.go --line 12 --message "use log here"
368gitbay mr diff-comment 4 --reply 7 --message "done" # join thread 7
369gitbay mr threads 4 # threads with staleness
370gitbay mr resolve 4 7 / unresolve 4 7
371#+end_src
372
373Threads render inline on the MR page. A force-push marks them stale
374(shown under "threads on earlier revisions") rather than guessing new
375anchors; =mr show= reports the unresolved count. Resolving is for the
376thread author, the MR author, or anyone with write.
377
378* CI builds
379
380A =.gitbay/ci.yml= in the repo runs jobs on every branch push:
381
382#+begin_src yaml
383jobs:
384 test:
385 steps:
386 - go test ./...
387#+end_src
388
389Each job becomes a build (=build list=, =build log=, the builds tab on
390the web) and a =ci/<job>= commit status, which =repo settings
391require-checks= can gate merges on. Steps run with =sh -c= on the
392instance's runner, stopping at the first failure; a broken config
393surfaces as a failed =ci/config= status. Environment: =GITBAY_REPO=,
394=GITBAY_SHA=, =GITBAY_REF=, =GITBAY_JOB=, =CI=true=.
395
396Secrets: =repo secret set <owner/name> <NAME>= reads the value from
397stdin (never argv) and injects it into the repo's builds as =$NAME=;
398=repo secret list= shows names only, and the value is never echoed
399back. Anyone with write access can read a secret from inside a build,
400so scope them accordingly.
401
402Schedules: a job with =schedule: "17 11,23 * * *"= (five-field cron,
403server-local time; lists, ranges, and steps supported) runs on its cron
404against the default branch instead of on push. A default-branch push
405registers or updates the schedule. A job with =tags: "v*"= runs when a
406matching tag is pushed — and only then; =schedule= and =tags= are
407mutually exclusive. =build trigger <owner/name> <job>= queues any job
408immediately, and =build cancel <owner/name> <n>= withdraws one, queued
409or running: a running build stops at the runner within seconds and the
410log says who cancelled it. Both need write access.
411
412* Large files (LFS)
413
414Standard Git LFS works over both transports with no setup beyond the
415usual =git lfs track=. SSH remotes authenticate through
416=git-lfs-authenticate= (deploy keys included: ro keys can download, rw
417keys upload); anonymous HTTPS clones of public repositories can fetch
418LFS objects with no credentials. Objects are verified against their
419sha256 on upload and capped at 512MB by default.
420
421* Pages
422
423On instances with =[pages] domain= set, a =pages= branch in any public
424repo is served as a static site: the repo named =pages= at
425=https://<you>.<domain>/=, every other repo at
426=https://<you>.<domain>/<repo>/=. Push HTML to publish; a CI job can
427build and push the branch for automatic deploys. Sites run on a
428separate origin — your scripts work, and the forge's cookies are out of
429reach.
430
431A repo can also serve its pages branch on a domain you own.
432=repo domain add <owner/name> <domain>= claims it and prints a DNS TXT
433challenge (=_gitbay-challenge.<domain>=); create the record, run
434=repo domain verify=, then point the domain's A/AAAA records at the
435instance (DNS-only if the domain sits behind a proxying provider — the
436instance issues its own certificates). Claims are exclusive per
437instance; unverified claims serve nothing and expire after 7 days.
438=repo domain list= reports pending/verified/expired.
439
440* Browser sessions
441
442=gitbay web login= mints a one-time URL; the session it opens lasts
443seven days. =gitbay web sessions list= shows each of yours by a short
444id with its creation and expiry, and =gitbay web sessions revoke <id>=
445or =--all= ends them from the terminal, which is where a lost laptop is
446handled.
447
448* Notifications
449
450When the instance has SMTP configured, activity mails you: someone
451opens an issue or MR on your repository, comments where you are a
452participant (author, commenter, reviewer), reviews, closes, or merges.
453You are never mailed about your own actions, and only verified primary
454addresses receive anything. Delivery retries on relay failure.
455
456* Scripting
457
458Every read command takes =--json= and emits one envelope:
459={"protocol_version": 1, "data": ...}=. stdout is data, stderr is
460messages. Exit codes are stable: 0 ok, 1 failure, 2 usage, 3 not found,
4614 denied, 5 server/protocol error. Nothing ever prompts; destructive
462commands take =--yes=.
463
464For HTTP automation see [[API]].
465
466* CLI setup
467
468#+begin_src sh
469gitbay remote add myforge forge.example.org [--port n] [--user u] --default
470gitbay remote list
471gitbay init [name] [--private] # git init + repo create + origin, in one step
472#+end_src
473
474Configuration lives at =~/.config/gitbay/config.toml=. The CLI shells
475out to your real =ssh=, so =~/.ssh/config=, the agent, and hardware keys
476all apply. It shares one connection per instance through a control
477socket under =~/.ssh=: the first command in five minutes pays the
478handshake and the rest ride it. =no_multiplex = true= on an instance in
479the config turns that off. Man pages: =gitbay man --dir <dir>=; completions:
480=gitbay completion bash|zsh|fish=.