Add CalVer versioning and rebuild the song page's fact line

Versions are YYYY.MM.DD-N, injected with -ldflags from a git tag, so no file
in the repo carries the number and a local build honestly says dev. The
version shows in the footer, the startup log and /healthz.

The song page's metadata was five different kinds of fact — artist, genre,
duration, provenance and a badge about the viewer — in one flat run with two
competing pills. Now the artist has its own line in the display face, and the
facts sit in four labelled cells like the spine of a cassette insert. The
submitter links to their profile, "oma kappale" became "lähetti: sinä", and
the clock time is gone: nobody needs the minute a song was published.
This commit is contained in:
Esa Kataja
2026-07-31 23:38:54 +03:00
parent 2e68feedfe
commit ac2cfaebac
9 changed files with 107 additions and 23 deletions
+3 -1
View File
@@ -1,9 +1,11 @@
FROM golang:1.26-alpine AS build 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 WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . 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 FROM alpine:3.24
# yt-dlp rots against YouTube. Alpine's active branch tracks it closely (3.24 carries the current # yt-dlp rots against YouTube. Alpine's active branch tracks it closely (3.24 carries the current
+15 -1
View File
@@ -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/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 | | [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 - **`main`** — released code only. Every commit on it is something that ran in production, or is
meant to. Tagged at each release. meant to. Tagged at each release.
- **`dev`** — current development, and whatever nightly builds get made. Work happens here and - **`dev`** — current development, and whatever nightly builds get made. Work happens here and
reaches `main` by merge at release time. 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. 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. Members have an address because it is their login and because mail is a planned feature.
+4 -1
View File
@@ -17,7 +17,10 @@ services:
restart: unless-stopped restart: unless-stopped
app: app:
build: . build:
context: .
args:
VERSION: ${VERSION:-dev}
environment: environment:
DATABASE_URL: postgres://levyraati:${POSTGRES_PASSWORD}@postgres:5432/levyraati DATABASE_URL: postgres://levyraati:${POSTGRES_PASSWORD}@postgres:5432/levyraati
ADMIN_USER: ${ADMIN_USER:-admin} ADMIN_USER: ${ADMIN_USER:-admin}
+6
View File
@@ -185,3 +185,9 @@ says so.
changing under a future client — and the first endpoint gets built the day something actually 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 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. 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`.
+7 -1
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"crypto/subtle" "crypto/subtle"
"fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
@@ -13,6 +14,9 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "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 { type config struct {
databaseURL string databaseURL string
adminUser string adminUser string
@@ -67,6 +71,7 @@ type app struct {
func main() { func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
slog.Info("starting", "ctx", "startup", "version", version)
cfg := loadConfig() cfg := loadConfig()
ctx := context.Background() ctx := context.Background()
@@ -126,7 +131,8 @@ func (a *app) memberMux() *http.ServeMux {
http.Error(w, "db down", http.StatusServiceUnavailable) http.Error(w, "db down", http.StatusServiceUnavailable)
return 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) mux.HandleFunc("GET /login", a.loginPage)
+4
View File
@@ -16,6 +16,8 @@ var assetFS embed.FS
var funcs = template.FuncMap{ var funcs = template.FuncMap{
"fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") }, "fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") },
// 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) }, "score": func(f *float64) string { return strconv.FormatFloat(*f, 'f', 1, 64) },
"value": 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 // Lets one board partial be called with a title and a list, instead of two near-identical
@@ -58,6 +60,7 @@ type page struct {
Path string Path string
Narrow bool // auth pages are a 420px column Narrow bool // auth pages are a 420px column
Queued int // songs still owed a review, shown in the nav Queued int // songs still owed a review, shown in the nav
Version string
Data any Data any
} }
@@ -70,6 +73,7 @@ func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name st
} }
p.Member = memberFrom(r.Context()) p.Member = memberFrom(r.Context())
p.Path = r.URL.Path p.Path = r.URL.Path
p.Version = version
if p.Member != nil { if p.Member != nil {
// The queue is a worklist, so its size belongs in the nav. // The queue is a worklist, so its size belongs in the nav.
a.pool.QueryRow(r.Context(), ` a.pool.QueryRow(r.Context(), `
+45 -2
View File
@@ -282,8 +282,48 @@ footer.sitefooter {
/* --- song page and reviews --- */ /* --- song page and reviews --- */
.songmeta { color: var(--muted); display: flex; flex-wrap: wrap; gap: var(--space-2); /* The artist is the second most important thing on the page, so it gets the display face and a
align-items: center; margin: calc(-1 * var(--space-2)) 0 var(--space-4); } 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); .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; } 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 { color: var(--muted); }
.copyright::before { content: "·"; margin: 0 var(--space-2); } .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); }
+1
View File
@@ -65,6 +65,7 @@
{{end}} {{end}}
<span class="slogan">We know good music, baby!</span> <span class="slogan">We know good music, baby!</span>
<span class="copyright">© Kessinen</span> <span class="copyright">© Kessinen</span>
<span class="version" title="Käytössä oleva versio">v{{.Version}}</span>
</footer> </footer>
{{with .Flash}} {{with .Flash}}
+12 -7
View File
@@ -1,13 +1,18 @@
{{define "content"}} {{define "content"}}
{{$s := .Data}} {{$s := .Data}}
<h1>{{$s.Title}}</h1> <h1>{{$s.Title}}</h1>
<p class="songmeta"> <p class="by">{{$s.Artist}}</p>
<strong>{{$s.Artist}}</strong>
<span class="badge genre">{{$s.GenreLabel}}</span> <!-- Four different kinds of fact, so four labelled cells rather than one run of text. "Oma
<span>{{$s.Length}}</span> kappale" belongs here as the submitter, not as a badge: it is a fact about you. -->
<span>lähetti {{$s.Submitter}} {{fidate $s.CreatedAt}}</span> <dl class="spec">
{{if $s.Own}}<span class="badge admin">oma kappale</span>{{end}} <div><dt>Genre</dt><dd>{{$s.GenreLabel}}</dd></div>
</p> <div><dt>Kesto</dt><dd>{{$s.Length}}</dd></div>
<div><dt>Lähetti</dt>
<dd>{{if $s.Own}}sinä{{else}}<a href="/profile/{{$s.SubmitterID}}">{{$s.Submitter}}</a>{{end}}</dd>
</div>
<div><dt>Julkaistu</dt><dd>{{fiday $s.CreatedAt}}</dd></div>
</dl>
{{if $s.CanEdit}} {{if $s.CanEdit}}
<details class="editbox"> <details class="editbox">