Add gh workflow for release, new fields in podium and versioning

This commit is contained in:
x0gp
2026-07-14 18:36:51 +04:00
parent 13495de5bd
commit 7a7913d2ee
8 changed files with 269 additions and 20 deletions
+67
View File
@@ -0,0 +1,67 @@
name: Release Go Binaries
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
build:
name: Build Binary
runs-on: ubuntu-latest
strategy:
matrix:
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: server/go.mod
- name: Build Binary
working-directory: server
run: |
mkdir -p bin
OUT_NAME="poc-server-${{ matrix.goos }}-${{ matrix.goarch }}"
if [ "${{ matrix.goos }}" = "windows" ]; then
OUT_NAME="${OUT_NAME}.exe"
fi
echo "Building for ${{ matrix.goos }}/${{ matrix.goarch }}..."
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build -ldflags "-s -w -X main.serverVersion=${{ github.ref_name }}" -o bin/${OUT_NAME} ./cmd/poc-server
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: poc-server-${{ matrix.goos }}-${{ matrix.goarch }}
path: server/bin/
release:
name: Create GitHub Release
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Download Artifacts
uses: actions/download-artifact@v4
with:
path: release-binaries
merge-multiple: true
- name: Create Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Creating GitHub Release for tag ${{ github.ref_name }}..."
gh release create "${{ github.ref_name }}" \
--title "Release ${{ github.ref_name }}" \
--generate-notes \
release-binaries/*
+26 -2
View File
@@ -62,7 +62,7 @@ import (
"github.com/x0gp/server/internal/transport" "github.com/x0gp/server/internal/transport"
) )
const serverVersion = "0.1.0-poc" var serverVersion = "0.1.0-poc"
var ( var (
upgrader = websocket.Upgrader{ upgrader = websocket.Upgrader{
@@ -95,9 +95,15 @@ func main() {
devMode = flag.Bool("dev", true, "enable development mode") devMode = flag.Bool("dev", true, "enable development mode")
seedRaces = flag.Bool("seed-races", false, "seed mock race data (finished/live/plans) on startup") seedRaces = flag.Bool("seed-races", false, "seed mock race data (finished/live/plans) on startup")
seedReset = flag.Bool("reset", false, "with --seed-races, wipe seed-managed data before inserting") seedReset = flag.Bool("reset", false, "with --seed-races, wipe seed-managed data before inserting")
version = flag.Bool("version", false, "print version and exit")
) )
flag.Parse() flag.Parse()
if *version {
fmt.Println(serverVersion)
return
}
if *addr != "" { if *addr != "" {
_ = os.Setenv("X0GP_HTTP_ADDR", *addr) _ = os.Setenv("X0GP_HTTP_ADDR", *addr)
} }
@@ -118,7 +124,7 @@ func main() {
} }
logger := newLogger(cfg.LogLevel) logger := newLogger(cfg.LogLevel)
logger.Info("starting x0gp poc-server", logger.Info(fmt.Sprintf("starting x0gp poc-server version %s", serverVersion),
"version", serverVersion, "version", serverVersion,
"addr", cfg.HTTPAddr, "addr", cfg.HTTPAddr,
"tick_hz", cfg.TickRate, "tick_hz", cfg.TickRate,
@@ -276,6 +282,7 @@ func main() {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/health", healthHandler(hub, engine)) mux.HandleFunc("/health", healthHandler(hub, engine))
mux.HandleFunc("/api/version", versionHandler())
mux.HandleFunc("/stats", statsHandler(hub, engine)) mux.HandleFunc("/stats", statsHandler(hub, engine))
mux.HandleFunc("/api/catalog", catalogHandler(cat)) mux.HandleFunc("/api/catalog", catalogHandler(cat))
mux.HandleFunc("/api/tracks", tracksRouter(cat)) mux.HandleFunc("/api/tracks", tracksRouter(cat))
@@ -366,6 +373,23 @@ func (h *Hub) BroadcastSnapshot(snap transport.RaceSnapshot) {
// HTTP handlers ------------------------------------------------------------ // HTTP handlers ------------------------------------------------------------
// versionHandler godoc
// @Summary Get server version
// @Description Returns the current server version.
// @Tags system
// @Produce json
// @Success 200 {object} transport.VersionResponse "Success"
// @Router /api/version [get]
func versionHandler() http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(transport.VersionResponse{
Version: serverVersion,
})
}
}
// healthHandler godoc // healthHandler godoc
// @Summary Liveness probe // @Summary Liveness probe
// @Description Returns 200 OK with current engine phase, WS connection count and total snapshot drops. Intended for load-balancer health checks. // @Description Returns 200 OK with current engine phase, WS connection count and total snapshot drops. Intended for load-balancer health checks.
+3
View File
@@ -485,6 +485,9 @@ func lobbyToWireFinished(m lobby.RaceMeta, podium []races.PodiumEntry) transport
DriverID: p.DriverID, DriverID: p.DriverID,
Name: p.Name, Name: p.Name,
TotalTimeMs: p.TotalTimeMs, TotalTimeMs: p.TotalTimeMs,
Nickname: p.Nickname,
AvatarUrl: p.AvatarUrl,
ClanID: p.ClanID,
}) })
} }
} }
+40
View File
@@ -1750,6 +1750,26 @@ const docTemplate = `{
} }
} }
}, },
"/api/version": {
"get": {
"description": "Returns the current server version.",
"produces": [
"application/json"
],
"tags": [
"system"
],
"summary": "Get server version",
"responses": {
"200": {
"description": "Success",
"schema": {
"$ref": "#/definitions/transport.VersionResponse"
}
}
}
}
},
"/api/video/control": { "/api/video/control": {
"post": { "post": {
"description": "Sends a text command (e.g. start, stop, left, right) to the ESP32 command port (UDP 8888).", "description": "Sends a text command (e.g. start, stop, left, right) to the ESP32 command port (UDP 8888).",
@@ -2552,6 +2572,13 @@ const docTemplate = `{
"transport.RacePodiumEntry": { "transport.RacePodiumEntry": {
"type": "object", "type": "object",
"properties": { "properties": {
"avatar_url": {
"type": "string"
},
"clan_id": {
"type": "string",
"example": "clan-bob"
},
"driver_id": { "driver_id": {
"type": "string", "type": "string",
"example": "driver-alice" "example": "driver-alice"
@@ -2560,6 +2587,10 @@ const docTemplate = `{
"type": "string", "type": "string",
"example": "driver-alice" "example": "driver-alice"
}, },
"nickname": {
"type": "string",
"example": "driver-alice"
},
"position": { "position": {
"type": "integer", "type": "integer",
"example": 1 "example": 1
@@ -2851,6 +2882,15 @@ const docTemplate = `{
"type": "number" "type": "number"
} }
} }
},
"transport.VersionResponse": {
"type": "object",
"properties": {
"version": {
"type": "string",
"example": "0.1.0-poc"
}
}
} }
} }
}` }`
+40
View File
@@ -1748,6 +1748,26 @@
} }
} }
}, },
"/api/version": {
"get": {
"description": "Returns the current server version.",
"produces": [
"application/json"
],
"tags": [
"system"
],
"summary": "Get server version",
"responses": {
"200": {
"description": "Success",
"schema": {
"$ref": "#/definitions/transport.VersionResponse"
}
}
}
}
},
"/api/video/control": { "/api/video/control": {
"post": { "post": {
"description": "Sends a text command (e.g. start, stop, left, right) to the ESP32 command port (UDP 8888).", "description": "Sends a text command (e.g. start, stop, left, right) to the ESP32 command port (UDP 8888).",
@@ -2550,6 +2570,13 @@
"transport.RacePodiumEntry": { "transport.RacePodiumEntry": {
"type": "object", "type": "object",
"properties": { "properties": {
"avatar_url": {
"type": "string"
},
"clan_id": {
"type": "string",
"example": "clan-bob"
},
"driver_id": { "driver_id": {
"type": "string", "type": "string",
"example": "driver-alice" "example": "driver-alice"
@@ -2558,6 +2585,10 @@
"type": "string", "type": "string",
"example": "driver-alice" "example": "driver-alice"
}, },
"nickname": {
"type": "string",
"example": "driver-alice"
},
"position": { "position": {
"type": "integer", "type": "integer",
"example": 1 "example": 1
@@ -2849,6 +2880,15 @@
"type": "number" "type": "number"
} }
} }
},
"transport.VersionResponse": {
"type": "object",
"properties": {
"version": {
"type": "string",
"example": "0.1.0-poc"
}
}
} }
} }
} }
+27
View File
@@ -478,12 +478,20 @@ definitions:
type: object type: object
transport.RacePodiumEntry: transport.RacePodiumEntry:
properties: properties:
avatar_url:
type: string
clan_id:
example: clan-bob
type: string
driver_id: driver_id:
example: driver-alice example: driver-alice
type: string type: string
name: name:
example: driver-alice example: driver-alice
type: string type: string
nickname:
example: driver-alice
type: string
position: position:
example: 1 example: 1
type: integer type: integer
@@ -677,6 +685,12 @@ definitions:
width_m: width_m:
type: number type: number
type: object type: object
transport.VersionResponse:
properties:
version:
example: 0.1.0-poc
type: string
type: object
host: localhost:8080 host: localhost:8080
info: info:
contact: contact:
@@ -1894,6 +1908,19 @@ paths:
summary: Get / update / delete a track by id summary: Get / update / delete a track by id
tags: tags:
- tracks - tracks
/api/version:
get:
description: Returns the current server version.
produces:
- application/json
responses:
"200":
description: Success
schema:
$ref: '#/definitions/transport.VersionResponse'
summary: Get server version
tags:
- system
/api/video/control: /api/video/control:
post: post:
description: Sends a text command (e.g. start, stop, left, right) to the ESP32 description: Sends a text command (e.g. start, stop, left, right) to the ESP32
+46 -6
View File
@@ -22,6 +22,9 @@ type PodiumEntry struct {
DriverID string `json:"driver_id" example:"driver-alice"` DriverID string `json:"driver_id" example:"driver-alice"`
Name string `json:"name" example:"driver-alice"` Name string `json:"name" example:"driver-alice"`
TotalTimeMs int64 `json:"total_time_ms" example:"123456"` TotalTimeMs int64 `json:"total_time_ms" example:"123456"`
Nickname string `json:"nickname" example:"driver-alice"`
AvatarUrl string `json:"avatar_url"`
ClanID string `json:"clan_id" example:"clan-bob"`
} }
// DriverResult is the per-driver outcome of a race. Stored in // DriverResult is the per-driver outcome of a race. Stored in
@@ -31,9 +34,13 @@ type PodiumEntry struct {
// fields are derived from this row set on read. // fields are derived from this row set on read.
type DriverResult struct { type DriverResult struct {
DriverID string DriverID string
Name string
TotalTimeMs int64 TotalTimeMs int64
BestLapMs int64 BestLapMs int64
Position *int // nil for DNF Position *int // nil for DNF
Nickname string `json:"nickname" example:"driver-alice"`
AvatarUrl string `json:"avatar_url"`
ClanID string `json:"clan_id" example:"clan-bob"`
} }
// FinishedRace is the on-disk shape of a completed race. Mirrors the // FinishedRace is the on-disk shape of a completed race. Mirrors the
@@ -587,8 +594,8 @@ func (s *PgStore) scanFinished(ctx context.Context, rows pgx.Rows) ([]FinishedRa
// and derives the Winner* and Podium fields. // and derives the Winner* and Podium fields.
func (s *PgStore) hydrateFinished(ctx context.Context, r *FinishedRace) error { func (s *PgStore) hydrateFinished(ctx context.Context, r *FinishedRace) error {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT driver_id, total_time_ms, best_lap_ms, position SELECT rd.driver_id, rd.total_time_ms, rd.best_lap_ms, rd.position, d.name, d.nickname, d.avatar_url, d.clan_id
FROM race_drivers FROM race_drivers rd left join drivers d on rd.driver_id = d.id
WHERE race_id = $1 WHERE race_id = $1
ORDER BY slot ASC, joined_ms ASC`, r.ID) ORDER BY slot ASC, joined_ms ASC`, r.ID)
if err != nil { if err != nil {
@@ -602,10 +609,28 @@ func (s *PgStore) hydrateFinished(ctx context.Context, r *FinishedRace) error {
for rows.Next() { for rows.Next() {
var dr DriverResult var dr DriverResult
var pos *int var pos *int
if err := rows.Scan(&dr.DriverID, &dr.TotalTimeMs, &dr.BestLapMs, &pos); err != nil { var name, nick, avatar, clanID *string
if err := rows.Scan(
&dr.DriverID, &dr.TotalTimeMs, &dr.BestLapMs, &pos,
&name, &nick, &avatar, &clanID,
); err != nil {
return err return err
} }
dr.Position = pos dr.Position = pos
if name != nil {
dr.Name = *name
} else {
dr.Name = dr.DriverID
}
if nick != nil {
dr.Nickname = *nick
}
if avatar != nil {
dr.AvatarUrl = *avatar
}
if clanID != nil {
dr.ClanID = *clanID
}
r.Results = append(r.Results, dr) r.Results = append(r.Results, dr)
r.DriverIDs = append(r.DriverIDs, dr.DriverID) r.DriverIDs = append(r.DriverIDs, dr.DriverID)
} }
@@ -621,7 +646,7 @@ func (s *PgStore) hydrateFinished(ctx context.Context, r *FinishedRace) error {
if bestTotal == -1 || dr.TotalTimeMs < bestTotal { if bestTotal == -1 || dr.TotalTimeMs < bestTotal {
bestTotal = dr.TotalTimeMs bestTotal = dr.TotalTimeMs
r.WinnerDriverID = dr.DriverID r.WinnerDriverID = dr.DriverID
r.WinnerName = dr.DriverID r.WinnerName = dr.Name
} }
if dr.BestLapMs > 0 && (r.BestLapMs == 0 || dr.BestLapMs < r.BestLapMs) { if dr.BestLapMs > 0 && (r.BestLapMs == 0 || dr.BestLapMs < r.BestLapMs) {
r.BestLapMs = dr.BestLapMs r.BestLapMs = dr.BestLapMs
@@ -632,13 +657,25 @@ func (s *PgStore) hydrateFinished(ctx context.Context, r *FinishedRace) error {
id string id string
t int64 t int64
best int64 best int64
name string
nick string
avatar string
clanID string
} }
var sorted []row var sorted []row
for _, dr := range r.Results { for _, dr := range r.Results {
if dr.TotalTimeMs <= 0 { if dr.TotalTimeMs <= 0 {
continue continue
} }
sorted = append(sorted, row{dr.DriverID, dr.TotalTimeMs, dr.BestLapMs}) sorted = append(sorted, row{
id: dr.DriverID,
t: dr.TotalTimeMs,
best: dr.BestLapMs,
name: dr.Name,
nick: dr.Nickname,
avatar: dr.AvatarUrl,
clanID: dr.ClanID,
})
} }
for i := 1; i < len(sorted); i++ { for i := 1; i < len(sorted); i++ {
for j := i; j > 0 && sorted[j-1].t > sorted[j].t; j-- { for j := i; j > 0 && sorted[j-1].t > sorted[j].t; j-- {
@@ -649,8 +686,11 @@ func (s *PgStore) hydrateFinished(ctx context.Context, r *FinishedRace) error {
r.Podium = append(r.Podium, PodiumEntry{ r.Podium = append(r.Podium, PodiumEntry{
Position: i + 1, Position: i + 1,
DriverID: sorted[i].id, DriverID: sorted[i].id,
Name: sorted[i].id, Name: sorted[i].name,
TotalTimeMs: sorted[i].t, TotalTimeMs: sorted[i].t,
Nickname: sorted[i].nick,
AvatarUrl: sorted[i].avatar,
ClanID: sorted[i].clanID,
}) })
} }
return nil return nil
+8
View File
@@ -229,6 +229,14 @@ type RacePodiumEntry struct {
DriverID string `json:"driver_id" example:"driver-alice"` DriverID string `json:"driver_id" example:"driver-alice"`
Name string `json:"name" example:"driver-alice"` Name string `json:"name" example:"driver-alice"`
TotalTimeMs int64 `json:"total_time_ms" example:"123456"` TotalTimeMs int64 `json:"total_time_ms" example:"123456"`
Nickname string `json:"nickname" example:"driver-alice"`
AvatarUrl string `json:"avatar_url"`
ClanID string `json:"clan_id" example:"clan-bob"`
}
// VersionResponse is the body of the version API.
type VersionResponse struct {
Version string `json:"version" example:"0.1.0-poc"`
} }
// LobbyDriver is the wire form of lobby.DriverMeta. // LobbyDriver is the wire form of lobby.DriverMeta.