build: stamp the commit into gitbayd and the runner !117
merged
merged by cmc on 2026-08-30 03:43 UTC
· krz/gitbay:build-provenance into main
7 files changed, +144 −1
Layout: unified · split
.gitignore
+1
| @@ -1,5 +1,6 @@ |
| 1 | /gitbay |
1 | /gitbay |
| 2 | /gitbayd |
2 | /gitbayd |
| |
3 | /gitbay-runner |
| 3 | *.db |
4 | *.db |
| 4 | /dist/ |
5 | /dist/ |
| 5 | CLAUDE.md |
6 | CLAUDE.md |
Makefile
+6 −1
| @@ -12,7 +12,12 @@ PORT ?= 2222 |
| 12 | CLI_DEST ?= /opt/homebrew/bin/gitbay |
12 | CLI_DEST ?= /opt/homebrew/bin/gitbay |
| 13 | |
13 | |
| 14 | CROSS := CGO_ENABLED=0 GOOS=linux GOARCH=amd64 |
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 | SERVER_BIN := dist/gitbayd-linux-amd64 |
21 | SERVER_BIN := dist/gitbayd-linux-amd64 |
| 17 | RUNNER_BIN := dist/gitbay-runner-linux-amd64 |
22 | RUNNER_BIN := dist/gitbay-runner-linux-amd64 |
| 18 | |
23 | |
cmd/gitbay-runner/main.go
+10
| @@ -20,6 +20,8 @@ import ( |
| 20 | "path/filepath" |
20 | "path/filepath" |
| 21 | "strings" |
21 | "strings" |
| 22 | "time" |
22 | "time" |
| |
23 | |
| |
24 | "gitbay.org/gitbay/internal/buildinfo" |
| 23 | ) |
25 | ) |
| 24 | |
26 | |
| 25 | type job struct { |
27 | type job struct { |
| @@ -50,8 +52,16 @@ func main() { |
| 50 | poll = flag.Duration("poll", 5*time.Second, "idle poll interval") |
52 | poll = flag.Duration("poll", 5*time.Second, "idle poll interval") |
| 51 | timeout = flag.Duration("timeout", 30*time.Minute, "per-build time limit") |
53 | timeout = flag.Duration("timeout", 30*time.Minute, "per-build time limit") |
| 52 | once = flag.Bool("once", false, "process at most one build, then exit") |
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 | flag.Parse() |
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 | r := &runner{ |
65 | r := &runner{ |
| 56 | remote: *remote, |
66 | remote: *remote, |
| 57 | cloneBase: *cloneBase, |
67 | cloneBase: *cloneBase, |
cmd/gitbayd/main.go
+5
| @@ -18,6 +18,7 @@ import ( |
| 18 | "golang.org/x/crypto/acme/autocert" |
18 | "golang.org/x/crypto/acme/autocert" |
| 19 | "golang.org/x/crypto/ssh" |
19 | "golang.org/x/crypto/ssh" |
| 20 | |
20 | |
| |
21 | "gitbay.org/gitbay/internal/buildinfo" |
| 21 | "gitbay.org/gitbay/internal/ci" |
22 | "gitbay.org/gitbay/internal/ci" |
| 22 | "gitbay.org/gitbay/internal/config" |
23 | "gitbay.org/gitbay/internal/config" |
| 23 | "gitbay.org/gitbay/internal/control" |
24 | "gitbay.org/gitbay/internal/control" |
| @@ -80,6 +81,7 @@ func main() { |
| 80 | hookCmd(), |
81 | hookCmd(), |
| 81 | authorizedKeysCmd(), |
82 | authorizedKeysCmd(), |
| 82 | shellCmd(), |
83 | shellCmd(), |
| |
84 | versionCmd(), |
| 83 | ) |
85 | ) |
| 84 | |
86 | |
| 85 | if err := root.Execute(); err != nil { |
87 | if err := root.Execute(); err != nil { |
| @@ -116,6 +118,9 @@ func serveCmd() *cobra.Command { |
| 116 | Use: "serve", |
118 | Use: "serve", |
| 117 | Short: "run the ssh, http, and git listeners", |
119 | Short: "run the ssh, http, and git listeners", |
| 118 | RunE: func(cmd *cobra.Command, args []string) error { |
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 | cfg, err := config.Load(configPath) |
124 | cfg, err := config.Load(configPath) |
| 120 | if err != nil { |
125 | if err != nil { |
| 121 | return err |
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 | } |