A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit f89140d141

f89140d141a2e5f88ef64660dfc516ebd0af5b48

parent: 6781fa358a

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24T20:32:36Z

Add vanity go-import serving, release script, changelog

Part of #26. [go_import] config maps module paths to repositories; a
?go-get=1 request at or under a mapped path answers with the go-import
meta tag before normal routing, so go install gitbay.org/gitbay/cmd/...
resolves. gitbay joins the reserved names. deploy/release.sh <tag>
cross-compiles reproducible linux/amd64, linux/arm64, darwin/arm64
binaries with SHA256SUMS. CHANGELOG.org records v0.1.0 and unreleased
work; install docs updated in both guides.
CHANGELOG.org added +54
@@ -0,0 +1,54 @@
1#+title: gitbay changelog
2
3Versioning follows semver from v0.1.0. Database migrations run
4automatically on daemon start; upgrade notes appear per release when
5anything beyond "replace the binary and restart" is needed.
6
7* Unreleased
8
9 Deploy keys: repo-bound CI keys (=repo deploy-key=), ro/rw, rename- and
10 transfer-proof.
11 Commit statuses (=status set/list=), combined state on MR pages, and a
12 =require-checks= merge gate.
13 Email notifications for issue and MR activity (participants with
14 verified addresses; never the actor).
15 Inline review threads on MR diffs (=mr diff-comment/threads/resolve=),
16 stale on force-push, =require-resolved= merge gate.
17 Required approvals with CODEOWNERS (=require-approvals=; latest review
18 wins, author excluded) and a =require-resolved= gate; merge gate order
19 is checks → approvals → CODEOWNERS → resolved threads → signatures.
20 Web design revamp: token-based stylesheet (light+dark), wordmark and
21 favicon, aligned layout grid, card-based listings, designed 404,
22 mobile pass.
23 Cross-references and mentions: =#N=, =!N=, =owner/name#N=, =@user=
24 autolink in issue/MR text, viewer-aware for private repos.
25 Archived repositories (read-only with badge) and repo topics.
26 Blame view with signature-aware attribution and 1000-line pages.
27 Repository search (name/description/topic) and per-repo code search
28 (=repo grep=, web search tab); blob line anchors.
29 Homepage: dashboard for logged-in users (pinned repos via =repo pin=,
30 open MRs and issues involving you), landing page for visitors, full
31 public listing at =/explore=; MR diffs collapsed by default.
32 Milestones (=milestone create/list/close=, =issue/mr milestone=) with
33 web progress; issue templates from =.gitbay/issue-template*.md=
34 (CLI =$EDITOR= prefill and web form).
35 Issue actions from commit messages landing on the default branch:
36 =closes/fixes/resolves #N= closes, bare =#N= leaves a reference
37 comment; once per issue+commit.
38 Vanity Go imports: =[go_import]= config serves go-import meta tags, so
39 =go install gitbay.org/gitbay/cmd/...@latest= works.
40 Release script: =deploy/release.sh <tag>= builds reproducible
41 linux/amd64, linux/arm64, and darwin/arm64 binaries with SHA256SUMS.
42
43Upgrade notes: migrations 0006–0014 apply on start. No config changes
44required; =[go_import]= and =web.mode = "accounts"= are opt-in.
45
46* v0.1.0 — 2026-08-24
47
48First tagged release: the complete CLI-first forge. SSH control plane
49(bare-OpenSSH usable), git over SSH/HTTPS/git-daemon, repos, issues,
50merge requests (ff/merge/squash/rebase with signature policy), OpenPGP
51and SSHSIG verification with retroactive re-verification, orgs, repo
52import, web UI (view-only or accounts mode), registration with invites
53and SMTP, HTTPS/JSON API with SSH-minted tokens, webhooks, backups,
54ACME TLS, systemd deployment.
deploy/release.sh added +38
@@ -0,0 +1,38 @@
1#!/bin/sh
2# Build release binaries for a tag: reproducible cross-compiled gitbay and
3# gitbayd with a checksum manifest.
4#
5# git checkout v0.2.0 && ./deploy/release.sh v0.2.0
6#
7# Reproducibility: CGO off, -trimpath, stripped, empty build id; the VCS
8# revision embedded by the toolchain is deterministic per commit. Anyone on
9# the same Go toolchain and commit gets byte-identical binaries.
10set -eu
11
12V="${1:-}"
13[ -n "$V" ] || { echo "usage: $0 <version-tag>" >&2; exit 2; }
14
15out="dist/release/$V"
16rm -rf "$out"
17mkdir -p "$out"
18
19for target in linux/amd64 linux/arm64 darwin/arm64; do
20 goos="${target%/*}"
21 goarch="${target#*/}"
22 for bin in gitbay gitbayd; do
23 name="${bin}-${V}-${goos}-${goarch}"
24 echo "building $name"
25 CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \
26 go build -trimpath -ldflags='-s -w -buildid=' \
27 -o "$out/$name" "./cmd/$bin"
28 done
29done
30
31cd "$out"
32if command -v sha256sum >/dev/null 2>&1; then
33 sha256sum -- * > SHA256SUMS
34else
35 shasum -a 256 -- * > SHA256SUMS
36fi
37echo "wrote $out/SHA256SUMS"
38cat SHA256SUMS
docs/admin.org +14
@@ -6,6 +6,13 @@ startup and on every admin command.
66
77 * Install
88
9Build from source (=go build ./cmd/gitbayd=), install via the vanity
10module path (=go install gitbay.org/gitbay/cmd/gitbayd@latest=), or use
11a release build: =deploy/release.sh <tag>= cross-compiles reproducible
12linux/amd64, linux/arm64, and darwin/arm64 binaries with a SHA256SUMS
13manifest (CGO off, trimpath, stripped — byte-identical per commit and
14toolchain).
15
916 #+begin_src sh
1017 install -m 755 gitbayd /usr/local/bin/
1118 adduser --system --group --home /var/lib/gitbay --shell /usr/sbin/nologin gitbay
@@ -95,6 +102,13 @@ contradiction; =--no-host-checks= skips port/path probes.
95102 Serves only public repositories that additionally ran
96103 =repo settings git-daemon <repo> on=.
97104
105** [go_import]
106Vanity Go module paths, one per line: ="host/module" = "owner/repo"=.
107Requests with =?go-get=1= at or under the module path answer with the
108go-import meta tag pointing at the repository's HTTPS clone URL, so
109=go install host/module/cmd/...@latest= resolves. The repository should
110be public (the module path itself confirms it exists).
111
98112 * Users, email, invites
99113
100114 #+begin_src sh
docs/users.org +10
@@ -5,6 +5,16 @@ Everything here works from stock OpenSSH — replace =gitbay= with
55 convenience (instance profiles, repo inference, =$EDITOR=), nothing more.
66 =ssh git@<host> help= lists every command the server knows.
77
8* Installing the CLI
9
10#+begin_src sh
11go install gitbay.org/gitbay/cmd/gitbay@latest # any platform with Go
12brew install krz/tap/gitbay # Homebrew (macOS/Linux)
13#+end_src
14
15Or build from source: =go build ./cmd/gitbay= in a clone of
16=https://gitbay.org/krz/gitbay.git=.
17
818 * Getting an account
919
1020 How you join depends on the instance's registration mode:
e2e/goimport_test.go added +34
@@ -0,0 +1,34 @@
1package e2e
2
3import (
4 "strings"
5 "testing"
6)
7
8func TestGoImportVanity(t *testing.T) {
9 inst := startInstanceWith(t, "[go_import]\n\"127.0.0.1/tool\" = \"alice/tool\"\n")
10 aliceKey := inst.newKey(t, "alice")
11 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
12 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/tool"); code != 0 {
13 t.Fatalf("repo create: %s", errOut)
14 }
15
16 want := `<meta name="go-import" content="127.0.0.1/tool git https://gitbay.test/alice/tool.git">`
17 status, body := inst.get(t, "/tool?go-get=1")
18 if status != 200 || !strings.Contains(body, want) {
19 t.Fatalf("module root: %d\n%s", status, body)
20 }
21 // Subpackages resolve to the same module.
22 status, body = inst.get(t, "/tool/cmd/x?go-get=1")
23 if status != 200 || !strings.Contains(body, want) {
24 t.Fatalf("subpackage: %d\n%s", status, body)
25 }
26 // Prefix boundary: /toolbox is not under the module.
27 if _, body = inst.get(t, "/toolbox?go-get=1"); strings.Contains(body, "go-import") {
28 t.Fatal("prefix leak: /toolbox matched")
29 }
30 // Without go-get the path routes normally (no owner "tool" -> 404).
31 if status, _ = inst.get(t, "/tool"); status != 404 {
32 t.Fatalf("normal routing broken: %d", status)
33 }
34}
internal/config/config.go +14
@@ -23,6 +23,10 @@ type Config struct {
2323 Webhooks Webhooks `toml:"webhooks"`
2424 Limits Limits `toml:"limits"`
2525 Mail Mail `toml:"mail"`
26 // GoImport maps vanity Go module paths to repositories, e.g.
27 // "gitbay.org/gitbay" = "krz/gitbay". Requests carrying ?go-get=1
28 // under a mapped path get a go-import meta tag.
29 GoImport map[string]string `toml:"go_import"`
2630 }
2731
2832 type Server struct {
@@ -171,6 +175,16 @@ func (c Config) Validate() error {
171175 errs = append(errs, err)
172176 }
173177
178 for module, repo := range c.GoImport {
179 host, _, ok := strings.Cut(module, "/")
180 if !ok || host == "" || !strings.Contains(host, ".") {
181 errs = append(errs, fmt.Errorf("go_import key %q must be host/path (e.g. gitbay.org/gitbay)", module))
182 }
183 if parts := strings.Split(repo, "/"); len(parts) != 2 || parts[0] == "" || parts[1] == "" {
184 errs = append(errs, fmt.Errorf("go_import value %q must be owner/name", repo))
185 }
186 }
187
174188 // Contradictions.
175189 if c.Mail.SMTPHost != "" && c.Mail.From == "" {
176190 errs = append(errs, errors.New("[mail] from is required when smtp_host is set"))
internal/httpd/routes.go +29 −2
@@ -1,6 +1,11 @@
11 package httpd
22
3import "net/http"
3import (
4 "fmt"
5 "net"
6 "net/http"
7 "strings"
8)
49
510 // Route is one entry in the explicit route table. The view-only guarantee is
611 // structural: Handler() consults web.mode when building the table, and the
@@ -88,5 +93,27 @@ func (s *Server) Handler() http.Handler {
8893 for _, r := range s.Routes() {
8994 mux.HandleFunc(r.Method+" "+r.Pattern, r.Handler)
9095 }
91 return mux
96 if len(s.cfg.GoImport) == 0 {
97 return mux
98 }
99 // Vanity Go modules: ?go-get=1 requests under a configured module
100 // path answer with the go-import meta tag before normal routing.
101 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
102 if r.URL.Query().Get("go-get") == "1" {
103 host := r.Host
104 if h, _, err := net.SplitHostPort(host); err == nil {
105 host = h
106 }
107 requested := host + strings.TrimSuffix(r.URL.Path, "/")
108 for module, repo := range s.cfg.GoImport {
109 if requested == module || strings.HasPrefix(requested, module+"/") {
110 w.Header().Set("Content-Type", "text/html; charset=utf-8")
111 fmt.Fprintf(w, `<!DOCTYPE html><html><head><meta name="go-import" content="%s git %s/%s.git"></head><body>%s</body></html>`,
112 module, s.cfg.Server.SiteURL, repo, module)
113 return
114 }
115 }
116 }
117 mux.ServeHTTP(w, r)
118 })
92119 }
internal/policy/names.go +1
@@ -16,6 +16,7 @@ var reservedNames = map[string]bool{
1616 "archive": true,
1717 "explore": true,
1818 "favicon.svg": true,
19 "gitbay": true, // vanity go-import path on gitbay.org
1920 "login": true,
2021 "logout": true,
2122 "new": true,