A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 57ae9420a9

57ae9420a97357fb6fd7f1f9c7bebae5c158d94e

parent: c7cebb36f2

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-08-31T23:06:27Z

Serve build badges as PNG as well as SVG

badge/build.png draws the same two-part pill as badge/build.svg, for
readers that cannot decode SVG. iOS is the case that forced it: UIImage
decodes SVG only from an asset catalog, so a native README view has no
way to render the SVG badge.

Drawn at 2x, since a raster badge has no other way to stay sharp and
carries no channel to declare its own scale. Text is Go Regular, the
only TTF in the module graph, measured against the real face rather
than the Verdana approximation the SVG needs because it ships no
metrics.

Adds golang.org/x/image for the rasterizer.
go.mod +1
@@ -32,6 +32,7 @@ require (
3232 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
3333 github.com/russross/blackfriday/v2 v2.1.0 // indirect
3434 github.com/spf13/pflag v1.0.9 // indirect
35 golang.org/x/image v0.45.0 // indirect
3536 golang.org/x/sys v0.47.0 // indirect
3637 golang.org/x/text v0.41.0 // indirect
3738 modernc.org/libc v1.74.4 // indirect
go.sum +2
@@ -65,6 +65,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
6565go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
6666golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
6767golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
68golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
69golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
6870golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
6971golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
7072golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
internal/httpd/badge.go +34 −7
@@ -62,28 +62,55 @@ func badgeSVG(label, state string) string {
6262
6363// buildBadge answers GET /{owner}/{repo}/badge/build.svg[?job=name].
6464func (s *Server) buildBadge(w http.ResponseWriter, r *http.Request) {
65 repo, ok := s.publicRepo(r.PathValue("owner"), strings.TrimSuffix(r.PathValue("repo"), ".git"))
65 label, state, ok := s.badgeState(r)
66 if !ok {
67 http.NotFound(w, r)
68 return
69 }
70 badgeHeaders(w, "image/svg+xml; charset=utf-8")
71 fmt.Fprint(w, badgeSVG(label, state))
72}
73
74// buildBadgePNG answers GET /{owner}/{repo}/badge/build.png[?job=name], for
75// readers that cannot decode SVG.
76func (s *Server) buildBadgePNG(w http.ResponseWriter, r *http.Request) {
77 label, state, ok := s.badgeState(r)
6678 if !ok {
6779 http.NotFound(w, r)
6880 return
6981 }
82 out, err := badgePNG(label, state)
83 if err != nil {
84 http.Error(w, "badge unavailable", http.StatusInternalServerError)
85 return
86 }
87 badgeHeaders(w, "image/png")
88 w.Write(out)
89}
90
91// badgeState resolves the repo and its latest build. Private repositories
92// report not ok — a badge must not leak that a repo exists.
93func (s *Server) badgeState(r *http.Request) (label, state string, ok bool) {
94 repo, ok := s.publicRepo(r.PathValue("owner"), strings.TrimSuffix(r.PathValue("repo"), ".git"))
95 if !ok {
96 return "", "", false
97 }
7098 job := r.URL.Query().Get("job")
71 label := "build"
99 label = "build"
72100 if job != "" {
73101 label = job
74102 }
75 state := "unknown"
103 state = "unknown"
76104 if b, err := s.st.LatestBuild(repo.ID, job); err == nil {
77105 state = b.Status
78106 }
79 writeBadge(w, label, state)
107 return label, state, true
80108}
81109
82func writeBadge(w http.ResponseWriter, label, state string) {
83 w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
110func badgeHeaders(w http.ResponseWriter, contentType string) {
111 w.Header().Set("Content-Type", contentType)
84112 // Badges are read by other people's caches; keep them briefly fresh
85113 // rather than pinned to a stale result.
86114 w.Header().Set("Cache-Control", "max-age=60, must-revalidate")
87115 w.Header().Set("X-Content-Type-Options", "nosniff")
88 fmt.Fprint(w, badgeSVG(label, state))
89116}
internal/httpd/badgepng.go added +161
@@ -0,0 +1,161 @@
1package httpd
2
3import (
4 "bytes"
5 "image"
6 "image/color"
7 "image/draw"
8 "image/png"
9 "sync"
10
11 "golang.org/x/image/font"
12 "golang.org/x/image/font/gofont/goregular"
13 "golang.org/x/image/font/opentype"
14 "golang.org/x/image/math/fixed"
15)
16
17// The PNG badge, for readers that cannot decode SVG. iOS is the case that
18// forced it: UIImage decodes SVG only from an asset catalog, so a native
19// README view has no way to render the SVG badge. It is drawn at 2x because a
20// raster badge has no other way to stay sharp, and a badge carries no channel
21// to declare its own scale.
22
23const (
24 badgeScale = 2
25 badgeHeight = 20
26 badgeRadius = 3
27 badgeFontPt = 11
28)
29
30// badgeFace is parsed once. Go Regular, not the Verdana the SVG names: the SVG
31// asks a browser for a font it already has, while a raster badge has to carry
32// one, and the Go fonts are the only TTF in the module graph.
33var badgeFace = sync.OnceValues(func() (font.Face, error) {
34 parsed, err := opentype.Parse(goregular.TTF)
35 if err != nil {
36 return nil, err
37 }
38 return opentype.NewFace(parsed, &opentype.FaceOptions{
39 Size: badgeFontPt * badgeScale,
40 DPI: 72,
41 // Hinting rounds stems to the pixel grid, which is what keeps 11px text
42 // legible rather than smeared.
43 Hinting: font.HintingFull,
44 })
45})
46
47// badgePNG draws the same two-part pill badgeSVG describes.
48//
49// Widths are measured against the real face rather than reusing badgeWidth's
50// Verdana approximation, which exists only so the SVG need not ship metrics.
51// Here the metrics are already loaded, so the pill fits its text exactly.
52func badgePNG(label, state string) ([]byte, error) {
53 face, err := badgeFace()
54 if err != nil {
55 return nil, err
56 }
57 fill := badgeColors[state]
58 if fill == "" {
59 fill = badgeColors["unknown"]
60 }
61
62 pad := 10 * badgeScale
63 lw := font.MeasureString(face, label).Ceil() + pad
64 sw := font.MeasureString(face, state).Ceil() + pad
65 h := badgeHeight * badgeScale
66 r := badgeRadius * badgeScale
67
68 img := image.NewRGBA(image.Rect(0, 0, lw+sw, h))
69 // Three fills, as in the SVG: the grey pill, the coloured pill over its
70 // right half, then a square patch squaring off that pill's left edge.
71 fillRoundRect(img, image.Rect(0, 0, lw+sw, h), r, mustHex("#444d56"))
72 fillRoundRect(img, image.Rect(lw, 0, lw+sw, h), r, mustHex(fill))
73 draw.Draw(img, image.Rect(lw, 0, lw+2*r, h), image.NewUniform(mustHex(fill)), image.Point{}, draw.Src)
74
75 // y=14 of 20 in the SVG is the baseline; it is the same fraction here.
76 baseline := 14 * badgeScale
77 drawCentered(img, face, label, lw/2, baseline)
78 drawCentered(img, face, state, lw+sw/2, baseline)
79
80 var out bytes.Buffer
81 if err := png.Encode(&out, img); err != nil {
82 return nil, err
83 }
84 return out.Bytes(), nil
85}
86
87func drawCentered(dst *image.RGBA, face font.Face, s string, centerX, baseline int) {
88 width := font.MeasureString(face, s)
89 drawer := font.Drawer{
90 Dst: dst,
91 Src: image.NewUniform(color.White),
92 Face: face,
93 Dot: fixed.P(centerX, baseline).Sub(fixed.Point26_6{X: width / 2}),
94 }
95 drawer.DrawString(s)
96}
97
98// fillRoundRect fills r with c, rounding its four corners by radius.
99func fillRoundRect(dst *image.RGBA, r image.Rectangle, radius int, c color.Color) {
100 for y := r.Min.Y; y < r.Max.Y; y++ {
101 for x := r.Min.X; x < r.Max.X; x++ {
102 if insideRoundRect(x, y, r, radius) {
103 dst.Set(x, y, c)
104 }
105 }
106 }
107}
108
109func insideRoundRect(x, y int, r image.Rectangle, radius int) bool {
110 // Corner centres, inset by the radius. A pixel outside the rectangle the
111 // corners inscribe is in a corner, and belongs only if it is within radius
112 // of the nearest centre.
113 cx, cy := x, y
114 switch {
115 case x < r.Min.X+radius:
116 cx = r.Min.X + radius
117 case x >= r.Max.X-radius:
118 cx = r.Max.X - radius - 1
119 }
120 switch {
121 case y < r.Min.Y+radius:
122 cy = r.Min.Y + radius
123 case y >= r.Max.Y-radius:
124 cy = r.Max.Y - radius - 1
125 }
126 if cx == x && cy == y {
127 return true
128 }
129 dx, dy := x-cx, y-cy
130 return dx*dx+dy*dy <= radius*radius
131}
132
133// mustHex parses the "#rrggbb" literals badgeColors holds. They are constants
134// in this package, so a bad one is a programming error, not input.
135func mustHex(s string) color.RGBA {
136 var r, g, b uint8
137 for i, shift := range []uint{0, 1, 2} {
138 hi, lo := hexNibble(s[1+i*2]), hexNibble(s[2+i*2])
139 v := hi<<4 | lo
140 switch shift {
141 case 0:
142 r = v
143 case 1:
144 g = v
145 case 2:
146 b = v
147 }
148 }
149 return color.RGBA{R: r, G: g, B: b, A: 255}
150}
151
152func hexNibble(c byte) uint8 {
153 switch {
154 case c >= '0' && c <= '9':
155 return c - '0'
156 case c >= 'a' && c <= 'f':
157 return c - 'a' + 10
158 default:
159 return 0
160 }
161}
internal/httpd/badgepng_test.go added +69
@@ -0,0 +1,69 @@
1package httpd
2
3import (
4 "bytes"
5 "image"
6 "image/color"
7 "image/png"
8 "testing"
9)
10
11func decodeBadge(t *testing.T, label, state string) image.Image {
12 t.Helper()
13 out, err := badgePNG(label, state)
14 if err != nil {
15 t.Fatalf("badgePNG(%q, %q): %v", label, state, err)
16 }
17 img, err := png.Decode(bytes.NewReader(out))
18 if err != nil {
19 t.Fatalf("decode %q: %v", state, err)
20 }
21 return img
22}
23
24func TestBadgePNGGeometry(t *testing.T) {
25 img := decodeBadge(t, "build", "success")
26 if got, want := img.Bounds().Dy(), badgeHeight*badgeScale; got != want {
27 t.Errorf("height = %d, want %d", got, want)
28 }
29 // A longer label makes a wider badge; the pill is sized to its text.
30 if decodeBadge(t, "integration", "success").Bounds().Dx() <= img.Bounds().Dx() {
31 t.Error("a longer label did not widen the badge")
32 }
33}
34
35func TestBadgePNGUsesTheStateColour(t *testing.T) {
36 // A point inside the right-hand pill, clear of the rounded corners.
37 for state, hex := range badgeColors {
38 img := decodeBadge(t, "build", state)
39 b := img.Bounds()
40 got := color.RGBAModel.Convert(img.At(b.Max.X-4, b.Dy()/2)).(color.RGBA)
41 if want := mustHex(hex); got != want {
42 t.Errorf("%s: pill colour = %v, want %v", state, got, want)
43 }
44 }
45}
46
47// An unknown state takes the grey the SVG gives it rather than a transparent pill.
48func TestBadgePNGUnknownStateFallsBack(t *testing.T) {
49 img := decodeBadge(t, "build", "no-such-state")
50 b := img.Bounds()
51 got := color.RGBAModel.Convert(img.At(b.Max.X-4, b.Dy()/2)).(color.RGBA)
52 if want := mustHex(badgeColors["unknown"]); got != want {
53 t.Errorf("pill colour = %v, want %v", got, want)
54 }
55}
56
57// The corners are rounded, so the very corner pixel is left transparent.
58func TestBadgePNGCornersAreRounded(t *testing.T) {
59 img := decodeBadge(t, "build", "success")
60 if _, _, _, a := img.At(img.Bounds().Min.X, img.Bounds().Min.Y).RGBA(); a != 0 {
61 t.Errorf("top-left alpha = %d, want 0", a)
62 }
63}
64
65func TestMustHex(t *testing.T) {
66 if got, want := mustHex("#2da44e"), (color.RGBA{R: 0x2d, G: 0xa4, B: 0x4e, A: 255}); got != want {
67 t.Errorf("mustHex = %v, want %v", got, want)
68 }
69}
internal/httpd/routes.go +1
@@ -59,6 +59,7 @@ func (s *Server) Routes() []Route {
5959 Route{Method: "GET", Pattern: "/{owner}/{repo}/releases", Handler: s.releases},
6060 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds", Handler: s.builds},
6161 Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.svg", Handler: s.buildBadge},
62 Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.png", Handler: s.buildBadgePNG},
6263 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds/{n}", Handler: s.build},
6364 Route{Method: "GET", Pattern: "/{owner}/{repo}/releases/download/{tag}/{name}", Handler: s.releaseAsset},
6465 Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw},