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 @@ |
| 1 | 1 | /gitbay |
| 2 | 2 | /gitbayd |
| 3 | /gitbay-runner |
| 3 | 4 | *.db |
| 4 | 5 | /dist/ |
| 5 | 6 | CLAUDE.md |
Makefile
+6 −1
| @@ -12,7 +12,12 @@ PORT ?= 2222 |
| 12 | 12 | CLI_DEST ?= /opt/homebrew/bin/gitbay |
| 13 | 13 | |
| 14 | 14 | CROSS := CGO_ENABLED=0 GOOS=linux GOARCH=amd64 |
| 15 | | LDFLAGS := -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. |
| 19 | COMMIT := $(shell git rev-parse --short=12 HEAD 2>/dev/null || echo unknown)$(shell [ -n "$$(git status --porcelain 2>/dev/null)" ] && echo -dirty) |
| 20 | LDFLAGS := -s -w -X gitbay.org/gitbay/internal/buildinfo.Commit=$(COMMIT) |
| 16 | 21 | SERVER_BIN := dist/gitbayd-linux-amd64 |
| 17 | 22 | RUNNER_BIN := dist/gitbay-runner-linux-amd64 |
| 18 | 23 | |
cmd/gitbay-runner/main.go
+10
| @@ -20,6 +20,8 @@ import ( |
| 20 | 20 | "path/filepath" |
| 21 | 21 | "strings" |
| 22 | 22 | "time" |
| 23 | |
| 24 | "gitbay.org/gitbay/internal/buildinfo" |
| 23 | 25 | ) |
| 24 | 26 | |
| 25 | 27 | type job struct { |
| @@ -50,8 +52,16 @@ func main() { |
| 50 | 52 | poll = flag.Duration("poll", 5*time.Second, "idle poll interval") |
| 51 | 53 | timeout = flag.Duration("timeout", 30*time.Minute, "per-build time limit") |
| 52 | 54 | 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") |
| 53 | 56 | ) |
| 54 | 57 | 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()) |
| 55 | 65 | r := &runner{ |
| 56 | 66 | remote: *remote, |
| 57 | 67 | cloneBase: *cloneBase, |
cmd/gitbayd/main.go
+5
| @@ -18,6 +18,7 @@ import ( |
| 18 | 18 | "golang.org/x/crypto/acme/autocert" |
| 19 | 19 | "golang.org/x/crypto/ssh" |
| 20 | 20 | |
| 21 | "gitbay.org/gitbay/internal/buildinfo" |
| 21 | 22 | "gitbay.org/gitbay/internal/ci" |
| 22 | 23 | "gitbay.org/gitbay/internal/config" |
| 23 | 24 | "gitbay.org/gitbay/internal/control" |
| @@ -80,6 +81,7 @@ func main() { |
| 80 | 81 | hookCmd(), |
| 81 | 82 | authorizedKeysCmd(), |
| 82 | 83 | shellCmd(), |
| 84 | versionCmd(), |
| 83 | 85 | ) |
| 84 | 86 | |
| 85 | 87 | if err := root.Execute(); err != nil { |
| @@ -116,6 +118,9 @@ func serveCmd() *cobra.Command { |
| 116 | 118 | Use: "serve", |
| 117 | 119 | Short: "run the ssh, http, and git listeners", |
| 118 | 120 | 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()) |
| 119 | 124 | cfg, err := config.Load(configPath) |
| 120 | 125 | if err != nil { |
| 121 | 126 | return err |
cmd/gitbayd/version.go
added
+21
| @@ -0,0 +1,21 @@ |
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | |
| 6 | "github.com/spf13/cobra" |
| 7 | |
| 8 | "gitbay.org/gitbay/internal/buildinfo" |
| 9 | ) |
| 10 | |
| 11 | func 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. |
| 4 | package buildinfo |
| 5 | |
| 6 | import "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. |
| 14 | var 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. |
| 19 | func 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 @@ |
| 1 | package buildinfo |
| 2 | |
| 3 | import ( |
| 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. |
| 11 | func 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. |
| 24 | func 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. |
| 41 | func 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 | } |