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