A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit b685adf5ab

b685adf5ab7af44c896322ce2843e7e597a55aaf

parent: f259df57cf

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-30T03:42:59Z

build: stamp the commit into gitbayd and the runner

A deployed binary could not say where it came from. Finding out meant
rebuilding from main and comparing hashes, which is how the stale runner
surfaced: nothing had touched cmd/gitbay-runner, but it links
internal/store, so it had been running unmerged code since the previous
night.

LDFLAGS now sets internal/buildinfo.Commit for every binary the Makefile
builds, with a -dirty suffix that only ALLOW_DIRTY=1 can produce. Without
a stamp the package falls back to the revision the toolchain embeds.

gitbayd logs the commit as its first line and gains a version subcommand;
the runner logs it at startup and takes -version.

Ref #28.
.gitignore +1
@@ -1,5 +1,6 @@
11/gitbay
22/gitbayd
3/gitbay-runner
34*.db
45/dist/
56CLAUDE.md
Makefile +6 −1
@@ -12,7 +12,12 @@ PORT ?= 2222
1212 CLI_DEST ?= /opt/homebrew/bin/gitbay
1313
1414 CROSS := CGO_ENABLED=0 GOOS=linux GOARCH=amd64
15LDFLAGS := -s -w
15
16# Stamp the commit into every binary so a deployed artifact can say where it
17# came from. The -dirty suffix only appears under ALLOW_DIRTY=1, since
18# preflight otherwise refuses to build an uncommitted tree.
19COMMIT := $(shell git rev-parse --short=12 HEAD 2>/dev/null || echo unknown)$(shell [ -n "$$(git status --porcelain 2>/dev/null)" ] && echo -dirty)
20LDFLAGS := -s -w -X gitbay.org/gitbay/internal/buildinfo.Commit=$(COMMIT)
1621 SERVER_BIN := dist/gitbayd-linux-amd64
1722 RUNNER_BIN := dist/gitbay-runner-linux-amd64
1823
cmd/gitbay-runner/main.go +10
@@ -20,6 +20,8 @@ import (
2020 "path/filepath"
2121 "strings"
2222 "time"
23
24 "gitbay.org/gitbay/internal/buildinfo"
2325 )
2426
2527 type job struct {
@@ -50,8 +52,16 @@ func main() {
5052 poll = flag.Duration("poll", 5*time.Second, "idle poll interval")
5153 timeout = flag.Duration("timeout", 30*time.Minute, "per-build time limit")
5254 once = flag.Bool("once", false, "process at most one build, then exit")
55 version = flag.Bool("version", false, "print the commit this binary was built from, then exit")
5356 )
5457 flag.Parse()
58 if *version {
59 fmt.Println(buildinfo.String())
60 return
61 }
62 // The runner links internal/store, so it goes stale on changes that never
63 // touch cmd/gitbay-runner. Say which commit is running.
64 log.Printf("gitbay-runner %s", buildinfo.String())
5565 r := &runner{
5666 remote: *remote,
5767 cloneBase: *cloneBase,
cmd/gitbayd/main.go +5
@@ -18,6 +18,7 @@ import (
1818 "golang.org/x/crypto/acme/autocert"
1919 "golang.org/x/crypto/ssh"
2020
21 "gitbay.org/gitbay/internal/buildinfo"
2122 "gitbay.org/gitbay/internal/ci"
2223 "gitbay.org/gitbay/internal/config"
2324 "gitbay.org/gitbay/internal/control"
@@ -80,6 +81,7 @@ func main() {
8081 hookCmd(),
8182 authorizedKeysCmd(),
8283 shellCmd(),
84 versionCmd(),
8385 )
8486
8587 if err := root.Execute(); err != nil {
@@ -116,6 +118,9 @@ func serveCmd() *cobra.Command {
116118 Use: "serve",
117119 Short: "run the ssh, http, and git listeners",
118120 RunE: func(cmd *cobra.Command, args []string) error {
121 // First line of every run: the journal then says which commit is
122 // serving, without rebuilding the binary to find out.
123 slog.Info("gitbayd starting", "commit", buildinfo.String())
119124 cfg, err := config.Load(configPath)
120125 if err != nil {
121126 return err
cmd/gitbayd/version.go added +21
@@ -0,0 +1,21 @@
1package main
2
3import (
4 "fmt"
5
6 "github.com/spf13/cobra"
7
8 "gitbay.org/gitbay/internal/buildinfo"
9)
10
11func versionCmd() *cobra.Command {
12 return &cobra.Command{
13 Use: "version",
14 Short: "print the commit this binary was built from",
15 Args: cobra.NoArgs,
16 RunE: func(cmd *cobra.Command, args []string) error {
17 fmt.Println(buildinfo.String())
18 return nil
19 },
20 }
21}
internal/buildinfo/buildinfo.go added +45
@@ -0,0 +1,45 @@
1// Package buildinfo reports which commit a binary was built from, so a
2// deployed artifact can state its provenance instead of needing a rebuild and
3// a hash comparison to infer it.
4package buildinfo
5
6import "runtime/debug"
7
8// Commit is stamped at link time by the Makefile:
9//
10// -X gitbay.org/gitbay/internal/buildinfo.Commit=<sha>
11//
12// It carries a -dirty suffix when the tree was not clean, which only happens
13// under ALLOW_DIRTY=1 since preflight otherwise refuses to build.
14var Commit string
15
16// String returns the build's commit. A binary built by hand rather than by the
17// Makefile has no stamp, so fall back to the revision the toolchain embeds;
18// that one is absent too when the build ran outside a checkout.
19func String() string {
20 if Commit != "" {
21 return Commit
22 }
23 info, ok := debug.ReadBuildInfo()
24 if !ok {
25 return "unknown"
26 }
27 var rev, modified string
28 for _, s := range info.Settings {
29 switch s.Key {
30 case "vcs.revision":
31 rev = s.Value
32 case "vcs.modified":
33 if s.Value == "true" {
34 modified = "-dirty"
35 }
36 }
37 }
38 if rev == "" {
39 return "unknown"
40 }
41 if len(rev) > 12 {
42 rev = rev[:12]
43 }
44 return rev + modified
45}
internal/buildinfo/buildinfo_test.go added +56
@@ -0,0 +1,56 @@
1package buildinfo
2
3import (
4 "os/exec"
5 "strings"
6 "testing"
7)
8
9// The link-time stamp wins. This is what the Makefile sets, and what a
10// deployed binary reports.
11func TestStringPrefersTheStamp(t *testing.T) {
12 prev := Commit
13 defer func() { Commit = prev }()
14
15 Commit = "abc123def456-dirty"
16 if got := String(); got != "abc123def456-dirty" {
17 t.Errorf("String() = %q, want the stamped value", got)
18 }
19}
20
21// Without a stamp it falls back to the revision the toolchain embeds. Under
22// `go test` that is this checkout, so the result is a short hex revision,
23// possibly with -dirty. It must never be empty.
24func TestStringFallsBackToTheVCSStamp(t *testing.T) {
25 prev := Commit
26 defer func() { Commit = prev }()
27
28 Commit = ""
29 got := String()
30 if got == "" {
31 t.Fatal("String() returned empty; it must always name something")
32 }
33 rev := strings.TrimSuffix(got, "-dirty")
34 if got != "unknown" && len(rev) != 12 {
35 t.Errorf("String() = %q; want \"unknown\" or a 12-char revision", got)
36 }
37}
38
39// The stamp the Makefile computes must match the commit actually checked out,
40// or a deployed binary would name the wrong one.
41func TestMakefileStampMatchesHEAD(t *testing.T) {
42 head, err := exec.Command("git", "rev-parse", "--short=12", "HEAD").Output()
43 if err != nil {
44 t.Skipf("not a git checkout: %v", err)
45 }
46 want := strings.TrimSpace(string(head))
47
48 prev := Commit
49 defer func() { Commit = prev }()
50 Commit = ""
51
52 got := strings.TrimSuffix(String(), "-dirty")
53 if got != "unknown" && got != want {
54 t.Errorf("String() = %q, but HEAD is %q", got, want)
55 }
56}