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:
+3
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ 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") },
|
||||||
"score": func(f *float64) string { return strconv.FormatFloat(*f, 'f', 1, 64) },
|
// Date without the clock: the minute a song was published is noise.
|
||||||
"value": func(f float64) string { return strconv.FormatFloat(f, 'f', 1, 64) },
|
"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
|
// Lets one board partial be called with a title and a list, instead of two near-identical
|
||||||
// partials per leaderboard.
|
// partials per leaderboard.
|
||||||
"dict": func(pairs ...any) map[string]any {
|
"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.
|
// page is everything the layout needs, plus whatever the page itself wants in Data.
|
||||||
type page struct {
|
type page struct {
|
||||||
Title string
|
Title string
|
||||||
Member *member
|
Member *member
|
||||||
Admin bool
|
Admin bool
|
||||||
Flash string
|
Flash string
|
||||||
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
|
||||||
Data any
|
Version string
|
||||||
|
Data any
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name string, p page) {
|
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.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
@@ -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); }
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user