diff --git a/Dockerfile b/Dockerfile index 84566a9..d34ab57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,11 @@ FROM golang:1.26-alpine AS build +# CalVer, injected at build so no file needs bumping by hand: docker build --build-arg VERSION=… +ARG VERSION=dev WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -o /levyraati . +RUN CGO_ENABLED=0 go build -ldflags "-X main.version=${VERSION}" -o /levyraati . FROM alpine:3.24 # yt-dlp rots against YouTube. Alpine's active branch tracks it closely (3.24 carries the current diff --git a/README.md b/README.md index 95868fe..eaa1dee 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,27 @@ Invite-only, no public registration. Built for about ten friends. | [docs/theme.md](docs/theme.md) | The visual language: tokens, type, and what differs from the theme handoff | | [docs/later.md](docs/later.md) | Deliberately not in v1, with the reasoning kept | -## Branches +## Branches and releases - **`main`** — released code only. Every commit on it is something that ran in production, or is meant to. Tagged at each release. - **`dev`** — current development, and whatever nightly builds get made. Work happens here and reaches `main` by merge at release time. +Versions are **CalVer: `YYYY.MM.DD-N`**, where `N` is the build number for that day, starting at 1. +The version is injected at build time, so no file in the repo carries it: + +```sh +git switch main && git merge --no-ff dev +git tag 2026.07.31-1 +VERSION=$(git describe --tags --exact-match) docker compose build app +docker compose up -d app +``` + +A plain `go build` reports `dev`, which is the honest answer for a local binary. The running +version appears in the footer, in the startup log line, and in `GET /healthz` — so "what is +actually deployed" is answerable without an SSH session. + The app never sends email — there is no verification, no password reset link, and no notifications. Members have an address because it is their login and because mail is a planned feature. diff --git a/docker-compose.yml b/docker-compose.yml index 0998dc4..ce4a21a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,10 @@ services: restart: unless-stopped app: - build: . + build: + context: . + args: + VERSION: ${VERSION:-dev} environment: DATABASE_URL: postgres://levyraati:${POSTGRES_PASSWORD}@postgres:5432/levyraati ADMIN_USER: ${ADMIN_USER:-admin} diff --git a/docs/decisions.md b/docs/decisions.md index 6c57e4a..bc2b3b3 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -185,3 +185,9 @@ says so. changing under a future client — and the first endpoint gets built the day something actually calls it. Both surfaces being thin adapters over one data function is already true of the page handlers, so adding the JSON side later stays a one-line-per-route job. +44. **CalVer, `YYYY.MM.DD-N`, injected at build time.** The date is the useful part: this app ships + when there is something to ship and gets rebuilt monthly for yt-dlp anyway, so a semantic + version would communicate nothing a date does not. `N` is the build number within that day, + starting at 1, for the second attempt at a release. The string lives in a git tag and reaches + the binary through `-ldflags`, so no file in the repo has to be bumped and a local build + honestly reports `dev`. It surfaces in the footer, the startup log and `/healthz`. diff --git a/main.go b/main.go index 79e93f0..2ae4d56 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "context" "crypto/subtle" + "fmt" "log/slog" "net/http" "os" @@ -13,6 +14,9 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) +// Set at build time with -ldflags "-X main.version=…". A local `go build` honestly says dev. +var version = "dev" + type config struct { databaseURL string adminUser string @@ -67,6 +71,7 @@ type app struct { func main() { slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) + slog.Info("starting", "ctx", "startup", "version", version) cfg := loadConfig() ctx := context.Background() @@ -126,7 +131,8 @@ func (a *app) memberMux() *http.ServeMux { http.Error(w, "db down", http.StatusServiceUnavailable) return } - w.Write([]byte("ok")) + // The version answers "what is actually running out there" without an SSH session. + fmt.Fprintf(w, "ok %s\n", version) }) mux.HandleFunc("GET /login", a.loginPage) diff --git a/render.go b/render.go index 73dc66c..5047d13 100644 --- a/render.go +++ b/render.go @@ -16,8 +16,10 @@ var assetFS embed.FS var funcs = template.FuncMap{ "fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") }, - "score": func(f *float64) string { return strconv.FormatFloat(*f, 'f', 1, 64) }, - "value": func(f float64) string { return strconv.FormatFloat(f, 'f', 1, 64) }, + // Date without the clock: the minute a song was published is noise. + "fiday": func(t time.Time) string { return t.Local().Format("2.1.2006") }, + "score": func(f *float64) string { return strconv.FormatFloat(*f, 'f', 1, 64) }, + "value": func(f float64) string { return strconv.FormatFloat(f, 'f', 1, 64) }, // Lets one board partial be called with a title and a list, instead of two near-identical // partials per leaderboard. "dict": func(pairs ...any) map[string]any { @@ -51,14 +53,15 @@ func init() { // page is everything the layout needs, plus whatever the page itself wants in Data. type page struct { - Title string - Member *member - Admin bool - Flash string - Path string - Narrow bool // auth pages are a 420px column - Queued int // songs still owed a review, shown in the nav - Data any + Title string + Member *member + Admin bool + Flash string + Path string + Narrow bool // auth pages are a 420px column + Queued int // songs still owed a review, shown in the nav + Version string + Data any } func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name string, p page) { @@ -70,6 +73,7 @@ func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name st } p.Member = memberFrom(r.Context()) p.Path = r.URL.Path + p.Version = version if p.Member != nil { // The queue is a worklist, so its size belongs in the nav. a.pool.QueryRow(r.Context(), ` diff --git a/static/style.css b/static/style.css index 54431c6..821bdcd 100644 --- a/static/style.css +++ b/static/style.css @@ -282,8 +282,48 @@ footer.sitefooter { /* --- song page and reviews --- */ -.songmeta { color: var(--muted); display: flex; flex-wrap: wrap; gap: var(--space-2); - align-items: center; margin: calc(-1 * var(--space-2)) 0 var(--space-4); } +/* The artist is the second most important thing on the page, so it gets the display face and a + line of its own rather than being one bold word inside a run of metadata. */ +.by { + font-family: var(--font-display); + font-size: 1.35rem; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--text); + margin: 0 0 var(--space-3); +} + +/* Four kinds of fact, four labelled cells — the spine of a cassette insert. */ +.spec { + display: flex; + flex-wrap: wrap; + margin: 0 0 var(--space-5); + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: var(--radius); + overflow: hidden; +} + +.spec > div { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 6rem; + padding: var(--space-2) var(--space-4); + border-right: 1px solid var(--hairline); +} + +.spec > div:last-child { border-right: 0; } + +.spec dt { + font-family: var(--font-display); + font-size: 0.65rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.spec dd { margin: 0; font-size: 0.95rem; } .intro { white-space: pre-wrap; background: var(--surface); padding: var(--space-4); border-left: 3px solid var(--hairline); border-radius: 0 var(--radius) var(--radius) 0; } @@ -937,3 +977,6 @@ img.avatar { object-fit: cover; } .copyright { color: var(--muted); } .copyright::before { content: "·"; margin: 0 var(--space-2); } + +.version { color: var(--muted); font-family: var(--font-display); } +.version::before { content: "·"; margin: 0 var(--space-2); } diff --git a/templates/layout.html b/templates/layout.html index c4816de..5231d49 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -65,6 +65,7 @@ {{end}} We know good music, baby! © Kessinen + v{{.Version}} {{with .Flash}} diff --git a/templates/song.html b/templates/song.html index 9d91085..26b8477 100644 --- a/templates/song.html +++ b/templates/song.html @@ -1,13 +1,18 @@ {{define "content"}} {{$s := .Data}}

{{$s.Title}}

-

- {{$s.Artist}} - {{$s.GenreLabel}} - {{$s.Length}} - lähetti {{$s.Submitter}} {{fidate $s.CreatedAt}} - {{if $s.Own}}oma kappale{{end}} -

+

{{$s.Artist}}

+ + +
+
Genre
{{$s.GenreLabel}}
+
Kesto
{{$s.Length}}
+
Lähetti
+
{{if $s.Own}}sinä{{else}}{{$s.Submitter}}{{end}}
+
+
Julkaistu
{{fiday $s.CreatedAt}}
+
{{if $s.CanEdit}}