feat(podcasts+ops): Apple launcher-scheme feeds + visible build stamp
Release / build-and-push (push) Successful in 3m29s
Release / build-and-push (push) Successful in 3m29s
Apple scheme normalization (follow-up to the Scribl discussion): Add Feed accepts the launcher spellings podcast pages ship next to plain RSS — itpc://, podcasts://, feed:// — and rewrites them to the https feed URL they wrap (scheme match case-insensitive). http stays and the guard still rejects it. Unit-pinned. Build stamp (stale-container drift guard): the 2026-10-29 podcast session lost an hour to a container running a build that predated the feature. version.Commit rides ldflags from a GIT_COMMIT build arg; the dev compose passes git rev-parse --short HEAD via the Makefile; the app logs 'Bookhoard <ver> (commit <hash>)' at boot and /api/health reports it — the UI and the repo can no longer silently diverge.
This commit is contained in:
1 parent
acbcc61ae4
commit
253d8d0e8e
8 files changed
+49
-2
No files matched your search
+3
-1
@@ -41,12 +41,14 @@ RUN npm run build:ts
|
|||||||
# Declared after `COPY . .` so a version bump only invalidates this layer.
|
# Declared after `COPY . .` so a version bump only invalidates this layer.
|
||||||
# The release workflow passes the git tag, e.g. APP_VERSION=v1.5.
|
# The release workflow passes the git tag, e.g. APP_VERSION=v1.5.
|
||||||
ARG APP_VERSION=dev
|
ARG APP_VERSION=dev
|
||||||
|
# Short commit hash of the built source (stale-container drift guard).
|
||||||
|
ARG GIT_COMMIT=none
|
||||||
|
|
||||||
# Build Go binary (cached unless Go files or generated code changes)
|
# Build Go binary (cached unless Go files or generated code changes)
|
||||||
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||||
--mount=type=cache,target=/root/.cache/go-build \
|
--mount=type=cache,target=/root/.cache/go-build \
|
||||||
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo \
|
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo \
|
||||||
-ldflags "-X bookhoard/internal/version.Version=${APP_VERSION}" \
|
-ldflags "-X bookhoard/internal/version.Version=${APP_VERSION} -X bookhoard/internal/version.Commit=${GIT_COMMIT}" \
|
||||||
-o main ./cmd/server
|
-o main ./cmd/server
|
||||||
|
|
||||||
# Materialize the Go module cache into an image layer so the test-runner
|
# Materialize the Go module cache into an image layer so the test-runner
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ endif
|
|||||||
# Override with: CONTAINER_RUNTIME=podman make rebuild-app
|
# Override with: CONTAINER_RUNTIME=podman make rebuild-app
|
||||||
CONTAINER_RUNTIME ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null)
|
CONTAINER_RUNTIME ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null)
|
||||||
|
|
||||||
|
# Build stamp (stale-container drift guard): the rebuilt image carries
|
||||||
|
# the source commit; the app logs it at boot and /api/health reports it.
|
||||||
|
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
|
||||||
|
|
||||||
# Dev compose stack: base prod file merged with the dev override (local build + tests).
|
# Dev compose stack: base prod file merged with the dev override (local build + tests).
|
||||||
# Prod deploy does NOT use this — it runs plain `docker compose` against the base file only.
|
# Prod deploy does NOT use this — it runs plain `docker compose` against the base file only.
|
||||||
COMPOSE := $(CONTAINER_RUNTIME) compose -f docker-compose.yml -f docker-compose.dev.yml
|
COMPOSE := $(CONTAINER_RUNTIME) compose -f docker-compose.yml -f docker-compose.dev.yml
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"bookhoard/internal/router"
|
"bookhoard/internal/router"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
"bookhoard/internal/sync"
|
"bookhoard/internal/sync"
|
||||||
|
"bookhoard/internal/version"
|
||||||
"bookhoard/templates"
|
"bookhoard/templates"
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
@@ -33,6 +34,7 @@ func (cv *CustomValidator) Validate(i interface{}) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
log.Printf("Bookhoard %s (commit %s) starting", version.Version, version.Commit)
|
||||||
cfg := config.LoadConfig()
|
cfg := config.LoadConfig()
|
||||||
|
|
||||||
dbPool, err := pgxpool.New(context.Background(), cfg.DatabaseURL())
|
dbPool, err := pgxpool.New(context.Background(), cfg.DatabaseURL())
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./Dockerfile
|
dockerfile: ./Dockerfile
|
||||||
|
args:
|
||||||
|
GIT_COMMIT: ${GIT_COMMIT:-none}
|
||||||
|
|
||||||
# Integration Tests - runs against containerized app and db (dev only)
|
# Integration Tests - runs against containerized app and db (dev only)
|
||||||
tests:
|
tests:
|
||||||
|
|||||||
@@ -1984,6 +1984,7 @@ func (cfg *Config) GetHealth(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"version": version.Version,
|
"version": version.Version,
|
||||||
|
"commit": version.Commit,
|
||||||
"database": "connected",
|
"database": "connected",
|
||||||
"scan": map[string]interface{}{
|
"scan": map[string]interface{}{
|
||||||
"scan_in_progress": cfg.Worker.HasActiveScans(),
|
"scan_in_progress": cfg.Worker.HasActiveScans(),
|
||||||
|
|||||||
@@ -290,3 +290,14 @@ func newInsecureFeedServer(t *testing.T, body string) *httptest.Server {
|
|||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
return srv
|
return srv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apple Podcast launcher schemes normalize to the https feed they wrap.
|
||||||
|
func TestNormalizeFeedURL(t *testing.T) {
|
||||||
|
assert.Equal(t, "https://x.test/feed.xml", normalizeFeedURL("itpc://x.test/feed.xml"))
|
||||||
|
assert.Equal(t, "https://x.test/feed.xml", normalizeFeedURL("podcasts://x.test/feed.xml"))
|
||||||
|
assert.Equal(t, "https://x.test/feed.xml", normalizeFeedURL("feed://x.test/feed.xml"))
|
||||||
|
assert.Equal(t, "https://x.test/feed.xml", normalizeFeedURL("ITPC://x.test/feed.xml"), "scheme match is case-insensitive")
|
||||||
|
assert.Equal(t, "https://x.test/feed.xml", normalizeFeedURL("https://x.test/feed.xml"))
|
||||||
|
assert.Equal(t, "http://x.test/feed.xml", normalizeFeedURL("http://x.test/feed.xml"), "http stays — the guard rejects it downstream")
|
||||||
|
assert.Equal(t, "", normalizeFeedURL(" "))
|
||||||
|
}
|
||||||
@@ -156,7 +156,7 @@ func (s *PodcastService) AddFeed(ctx context.Context, userID pgtype.UUID, isAdmi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
feedURL := strings.TrimSpace(rawFeedURL)
|
feedURL := normalizeFeedURL(rawFeedURL)
|
||||||
if feedURL == "" {
|
if feedURL == "" {
|
||||||
return nil, errBadRequest("feed URL is required")
|
return nil, errBadRequest("feed URL is required")
|
||||||
}
|
}
|
||||||
@@ -711,6 +711,23 @@ func sortedStringSet(set map[string]bool) []string {
|
|||||||
|
|
||||||
// classifyFetchError maps guard/HTTP errors to user-facing text (the
|
// classifyFetchError maps guard/HTTP errors to user-facing text (the
|
||||||
// add-feed dialog and the download_error column both render it).
|
// add-feed dialog and the download_error column both render it).
|
||||||
|
// normalizeFeedURL accepts the launcher-scheme spellings podcast
|
||||||
|
// pages hand out alongside plain RSS (issue follow-up: "one says RSS,
|
||||||
|
// the other says Apple Podcast") and rewrites them to the https URL
|
||||||
|
// they wrap: itpc:// and podcasts:// are Apple's subscribe-launcher
|
||||||
|
// aliases for the SAME feed document; feed:// is the generic form.
|
||||||
|
// Anything else keeps its scheme — guardFetch enforces https.
|
||||||
|
func normalizeFeedURL(raw string) string {
|
||||||
|
u := strings.TrimSpace(raw)
|
||||||
|
lowered := strings.ToLower(u)
|
||||||
|
for _, prefix := range []string{"itpc://", "podcasts://", "feed://"} {
|
||||||
|
if strings.HasPrefix(lowered, prefix) {
|
||||||
|
return "https://" + u[len(prefix):]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
func classifyFetchError(err error) error {
|
func classifyFetchError(err error) error {
|
||||||
if errors.Is(err, ErrBlockedPrivateAddress) {
|
if errors.Is(err, ErrBlockedPrivateAddress) {
|
||||||
return fmt.Errorf("blocked: private/loopback addresses are not fetchable")
|
return fmt.Errorf("blocked: private/loopback addresses are not fetchable")
|
||||||
|
|||||||
@@ -8,3 +8,11 @@ package version
|
|||||||
// The release workflow passes the git tag (e.g. v1.5) as the APP_VERSION
|
// The release workflow passes the git tag (e.g. v1.5) as the APP_VERSION
|
||||||
// Docker build arg, which the Dockerfile forwards to this flag.
|
// Docker build arg, which the Dockerfile forwards to this flag.
|
||||||
var Version = "dev"
|
var Version = "dev"
|
||||||
|
|
||||||
|
// Commit is the git commit the binary was built from (short hash),
|
||||||
|
// injected alongside Version via ldflags (GIT_COMMIT build arg). It
|
||||||
|
// exists so "the UI I'm looking at" vs "the code in the repo" drift is
|
||||||
|
// VISIBLE — the 2026-10-29 podcast debugging session burned an hour on
|
||||||
|
// a stale container that predated the feature. "none" for unstampeded
|
||||||
|
// local builds.
|
||||||
|
var Commit = "none"
|
||||||
Reference in new issue
Block a user