Browse Source

Add release notes pages built from pull-releases

- /releases lists every pull-release record, newest first, with number
  and version; /releases/<NNN-name>/ shows the full notes and
  screenshots (click for full size). The About page links to the list.
- pull-releases/ is embedded in the binary so the notes always match the
  running code; only .png files in known release folders are served.
- A test requires every committed record to load and have both
  screenshots.
- Version 0.3.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude 1 day ago
parent
commit
619e0269ae

+ 3 - 1
README.md

@@ -37,7 +37,9 @@ Posts go through the normal branch-and-PR flow; never push to `main`.
 |---|---|
 | `/` | All posts, newest first |
 | `/posts/<slug>` | One post, with previous/next links |
-| `/about` | Version, post count, and how to write a post |
+| `/about` | Version, release notes link, post count, and how to write a post |
+| `/releases` | Release notes, newest first, from `pull-releases/` |
+| `/releases/<NNN-name>/` | One release's full notes and screenshots |
 | `/feed.xml` | RSS feed (linked in the footer) |
 | anything else | 404 page |
 

+ 32 - 12
main.go

@@ -23,6 +23,10 @@ type app struct {
 	siteURL  string // absolute base URL, no trailing slash
 	siteName string
 	commit   string
+
+	releaseFS   fs.FS // pull-releases/
+	releases    []*Release
+	releaseByID map[string]*Release
 }
 
 func newApp(contentDir, siteURL string) (*app, error) {
@@ -44,15 +48,26 @@ func newApp(contentDir, siteURL string) (*app, error) {
 	if err := md.Convert(aboutMarkdown, &about); err != nil {
 		return nil, err
 	}
+	relFS, err := fs.Sub(releaseFS, "pull-releases")
+	if err != nil {
+		return nil, err
+	}
+	releases, byID, err := loadReleases(relFS, md)
+	if err != nil {
+		return nil, err
+	}
 	return &app{
-		store:    NewStore(contentDir, md),
-		config:   newConfigFile(filepath.Join(contentDir, "site.json")),
-		pages:    pages,
-		assets:   assets,
-		about:    template.HTML(about.String()),
-		siteURL:  siteURL,
-		siteName: u.Host,
-		commit:   buildCommit(),
+		store:       NewStore(contentDir, md),
+		config:      newConfigFile(filepath.Join(contentDir, "site.json")),
+		pages:       pages,
+		assets:      assets,
+		about:       template.HTML(about.String()),
+		siteURL:     siteURL,
+		siteName:    u.Host,
+		commit:      buildCommit(),
+		releaseFS:   relFS,
+		releases:    releases,
+		releaseByID: byID,
 	}, nil
 }
 
@@ -67,6 +82,10 @@ func (a *app) routes() http.Handler {
 	mux.HandleFunc("GET /{$}", a.index)
 	mux.HandleFunc("GET /posts/{slug}", a.post)
 	mux.HandleFunc("GET /about", a.aboutPage)
+	mux.HandleFunc("GET /releases", a.releaseList)
+	mux.HandleFunc("GET /releases/{id}", a.releaseRedirect)
+	mux.HandleFunc("GET /releases/{id}/{$}", a.releasePage)
+	mux.HandleFunc("GET /releases/{id}/{file}", a.releaseFile)
 	mux.HandleFunc("GET /feed.xml", a.feed)
 	mux.HandleFunc("GET /static/", func(w http.ResponseWriter, r *http.Request) {
 		if strings.HasSuffix(r.URL.Path, "/") { // no directory listings
@@ -123,10 +142,11 @@ func (a *app) aboutPage(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 	a.render(w, http.StatusOK, "about", pageData{
-		Posts:   idx.posts,
-		Version: version,
-		Commit:  a.commit,
-		About:   a.about,
+		Posts:    idx.posts,
+		Releases: a.releases,
+		Version:  version,
+		Commit:   a.commit,
+		About:    a.about,
 	})
 }
 

+ 1 - 1
pull-releases/001-initial/README.md

@@ -30,6 +30,6 @@ Taken afterwards from a build of the branch's final commit (`0bd42c4`).
 
 | Desktop | Mobile |
 |---|---|
-| ![Desktop](desktop.png) | ![Mobile](mobile.png) |
+| [![Desktop](desktop.png)](desktop.png) | [![Mobile](mobile.png)](mobile.png) |
 
 🤖 Generated with [Claude Code](https://claude.com/claude-code)

+ 1 - 1
pull-releases/002-header-about-config/README.md

@@ -27,6 +27,6 @@ Taken afterwards from a build of the branch's final commit (`ac317db`).
 
 | Desktop | Mobile |
 |---|---|
-| ![Desktop](desktop.png) | ![Mobile](mobile.png) |
+| [![Desktop](desktop.png)](desktop.png) | [![Mobile](mobile.png)](mobile.png) |
 
 🤖 Generated with [Claude Code](https://claude.com/claude-code)

+ 1 - 1
pull-releases/003-css-cache-busting/README.md

@@ -26,6 +26,6 @@ From the branch's build (`a5ee315`) running locally.
 
 | Desktop | Mobile |
 |---|---|
-| ![Desktop](desktop.png) | ![Mobile](mobile.png) |
+| [![Desktop](desktop.png)](desktop.png) | [![Mobile](mobile.png)](mobile.png) |
 
 🤖 Generated with [Claude Code](https://claude.com/claude-code)

+ 38 - 0
pull-releases/004-release-notes-page/README.md

@@ -0,0 +1,38 @@
+# Add release notes pages
+
+Branch `feature/release-notes-page` · version 0.3.0
+
+## What changed
+
+- **About page:** a new **Release notes** row near the top of the info box, linking to the release list and showing how many releases there are.
+- **Release list (`/releases`):** every pull-release record, newest first, showing title, release number and version.
+- **Release page (`/releases/<NNN-name>/`):** the record's full notes rendered from its `README.md`, with the desktop and mobile screenshots. Clicking a screenshot opens it full size. A link at the bottom goes back to the list.
+- **Records built into the app:** `pull-releases/` is now embedded in the binary, so the notes on the site always match the code that's running, and no deploy changes are needed. This replaces 003's note that the folder isn't part of the app.
+- **Screenshot layout:** in release pages the screenshot table fills the column, with the desktop image about three times as wide as the mobile one so both come out about the same height.
+- **Records:** the screenshots in records 001–003 now link to their full-size images; `pull-releases/README.md` documents the format the site expects.
+- **Version:** 0.3.0.
+
+## Notes
+
+- Built on `feature/css-cache-busting` (003), which adds `pull-releases/`. Merge 003 first; this PR's compare link then shows only its own changes.
+- Only `.png` files in a known release folder are served; everything else under `/releases/` is a 404.
+- `/releases/<NNN-name>` without the trailing slash redirects to the slash form, so the screenshots' relative links resolve inside the release's folder.
+
+## Testing
+
+- All tests pass with the race detector (`go test -race ./...`).
+- New tests:
+  - parsing records: newest-first ordering, title and version, CRLF files, and skipping folders that don't follow the format
+  - every committed record loads and has both screenshots, so a broken record fails the tests instead of disappearing from the site
+  - the list, release page, screenshot serving, redirect, 404s including path traversal, and the About page link
+- Built and ran locally, and viewed the About page, the release list and a release page at desktop and mobile widths.
+
+## Screenshots
+
+From the branch's build running locally. The home page doesn't change in this PR.
+
+| Desktop | Mobile |
+|---|---|
+| [![Desktop](desktop.png)](desktop.png) | [![Mobile](mobile.png)](mobile.png) |
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)

BIN
pull-releases/004-release-notes-page/desktop.png


BIN
pull-releases/004-release-notes-page/mobile.png


+ 9 - 3
pull-releases/README.md

@@ -16,12 +16,18 @@ To open the PR on Gogs, use the compare link for the branch, paste the first
 line of `README.md` as the title and the rest as the description.
 
 Screenshots are taken from the branch's build running locally, before it is
-deployed, so they show exactly what the PR will put live. When a PR mainly
-changes a page other than the home page, that page is captured as well (for
-example `about-desktop.png`).
+deployed, so they show exactly what the PR will put live.
+
+The records are built into the app and published at `/releases` (linked from
+the About page). The first line must be a `# Title` heading, and a
+`version X.Y.Z` on the line below it is shown in the list. Embed the
+screenshots as `[![Desktop](desktop.png)](desktop.png)` so they link to the
+full-size image. A test fails if a folder is misnamed, is missing a
+screenshot, or its `README.md` doesn't load.
 
 | PR | Change |
 |---|---|
 | [001](001-initial/) | Markdown blog server |
 | [002](002-header-about-config/) | About page, configurable Home link, RSS footer, MIT license |
 | [003](003-css-cache-busting/) | Content hash on the stylesheet URL; this folder |
+| [004](004-release-notes-page/) | Release notes pages on the site |

+ 125 - 0
releases.go

@@ -0,0 +1,125 @@
+package main
+
+import (
+	"bytes"
+	"embed"
+	"fmt"
+	"html/template"
+	"io/fs"
+	"log"
+	"net/http"
+	"regexp"
+	"sort"
+
+	"github.com/yuin/goldmark"
+)
+
+// The pull-release records are built into the binary, so the release notes
+// on the site always match the code that is running.
+//
+//go:embed pull-releases
+var releaseFS embed.FS
+
+// Release is one pull-release record: pull-releases/NNN-name/README.md plus
+// its screenshots.
+type Release struct {
+	ID      string // folder name, e.g. "003-css-cache-busting"
+	Number  string // "003"
+	Title   string
+	Version string // "" if the record doesn't name one
+	HTML    template.HTML
+}
+
+var (
+	releaseDir     = regexp.MustCompile(`^(\d{3})-[a-z0-9][a-z0-9-]*$`)
+	releaseVersion = regexp.MustCompile(`\bversion (\d+\.\d+\.\d+)`)
+	releaseImage   = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*\.png$`)
+)
+
+// loadReleases reads every NNN-name/README.md in fsys, newest first. The
+// first line must be a "# Title" heading; it is removed from the body.
+// Folders that don't follow the convention are logged and skipped.
+func loadReleases(fsys fs.FS, md goldmark.Markdown) ([]*Release, map[string]*Release, error) {
+	entries, err := fs.ReadDir(fsys, ".")
+	if err != nil {
+		return nil, nil, err
+	}
+	var list []*Release
+	byID := map[string]*Release{}
+	for _, e := range entries {
+		m := releaseDir.FindStringSubmatch(e.Name())
+		if !e.IsDir() || m == nil {
+			continue
+		}
+		r, err := parseRelease(fsys, e.Name(), m[1], md)
+		if err != nil {
+			log.Printf("skipping release %s: %v", e.Name(), err)
+			continue
+		}
+		list = append(list, r)
+		byID[r.ID] = r
+	}
+	sort.Slice(list, func(i, j int) bool { return list[i].ID > list[j].ID })
+	return list, byID, nil
+}
+
+func parseRelease(fsys fs.FS, id, number string, md goldmark.Markdown) (*Release, error) {
+	src, err := fs.ReadFile(fsys, id+"/README.md")
+	if err != nil {
+		return nil, err
+	}
+	title, body := takeHeading(bytes.ReplaceAll(src, []byte("\r\n"), []byte("\n")))
+	if title == "" {
+		return nil, fmt.Errorf("README.md must start with a # Title line")
+	}
+	r := &Release{ID: id, Number: number, Title: title}
+	if m := releaseVersion.FindSubmatch(body); m != nil {
+		r.Version = string(m[1])
+	}
+	var buf bytes.Buffer
+	if err := md.Convert(body, &buf); err != nil {
+		return nil, err
+	}
+	r.HTML = template.HTML(buf.String())
+	return r, nil
+}
+
+func (a *app) releaseList(w http.ResponseWriter, r *http.Request) {
+	a.render(w, http.StatusOK, "releases", pageData{Releases: a.releases})
+}
+
+// releaseRedirect adds the trailing slash, so the screenshots' relative
+// links (desktop.png) resolve inside the release's folder.
+func (a *app) releaseRedirect(w http.ResponseWriter, r *http.Request) {
+	if _, ok := a.releaseByID[r.PathValue("id")]; !ok {
+		a.notFound(w, r)
+		return
+	}
+	http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
+}
+
+func (a *app) releasePage(w http.ResponseWriter, r *http.Request) {
+	rel, ok := a.releaseByID[r.PathValue("id")]
+	if !ok {
+		a.notFound(w, r)
+		return
+	}
+	a.render(w, http.StatusOK, "release", pageData{Release: rel})
+}
+
+// releaseFile serves a release's screenshots; nothing else in the folder.
+func (a *app) releaseFile(w http.ResponseWriter, r *http.Request) {
+	id, file := r.PathValue("id"), r.PathValue("file")
+	if _, ok := a.releaseByID[id]; !ok || !releaseImage.MatchString(file) {
+		a.notFound(w, r)
+		return
+	}
+	b, err := fs.ReadFile(a.releaseFS, id+"/"+file)
+	if err != nil {
+		a.notFound(w, r)
+		return
+	}
+	w.Header().Set("Content-Type", "image/png")
+	w.Header().Set("Cache-Control", "public, max-age=3600")
+	w.Write(b)
+}

+ 149 - 0
releases_test.go

@@ -0,0 +1,149 @@
+package main
+
+import (
+	"io/fs"
+	"net/http"
+	"strings"
+	"testing"
+	"testing/fstest"
+)
+
+func TestLoadReleases(t *testing.T) {
+	fsys := fstest.MapFS{
+		"README.md":              {Data: []byte("# Index, not a release\n")},
+		"001-first/README.md":    {Data: []byte("# First\n\nBranch `a` · version (none)\n\nBody one.\n")},
+		"002-second/README.md":   {Data: []byte("# Second\r\n\r\nBranch `b` · version 0.2.0\r\n\r\n- **Version:** 0.2.0\r\n")},
+		"002-second/desktop.png": {Data: []byte("png")},
+		"003-no-title/README.md": {Data: []byte("No heading here.\n")},
+		"004-no-readme/x.png":    {Data: []byte("png")},
+		"Bad_Name/README.md":     {Data: []byte("# Bad\n")},
+	}
+	list, byID, err := loadReleases(fsys, newMarkdown())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(list) != 2 || list[0].ID != "002-second" || list[1].ID != "001-first" {
+		t.Fatalf("got %d releases, want 002 then 001: %+v", len(list), list)
+	}
+	r := byID["002-second"]
+	if r.Number != "002" || r.Title != "Second" || r.Version != "0.2.0" {
+		t.Errorf("002: %+v", r)
+	}
+	if byID["001-first"].Version != "" {
+		t.Errorf("001: version = %q, want none", byID["001-first"].Version)
+	}
+	if strings.Contains(string(r.HTML), "<h1") {
+		t.Errorf("title heading repeated in body: %s", r.HTML)
+	}
+}
+
+// Every record committed under pull-releases/ must load, so a malformed one
+// fails the build's tests instead of silently disappearing from the site.
+func TestEmbeddedReleasesAllLoad(t *testing.T) {
+	relFS, err := fs.Sub(releaseFS, "pull-releases")
+	if err != nil {
+		t.Fatal(err)
+	}
+	entries, err := fs.ReadDir(relFS, ".")
+	if err != nil {
+		t.Fatal(err)
+	}
+	var dirs int
+	for _, e := range entries {
+		if e.IsDir() {
+			dirs++
+			if !releaseDir.MatchString(e.Name()) {
+				t.Errorf("pull-releases/%s: folder name must be NNN-name", e.Name())
+			}
+			for _, img := range []string{"desktop.png", "mobile.png"} {
+				if _, err := fs.Stat(relFS, e.Name()+"/"+img); err != nil {
+					t.Errorf("pull-releases/%s: missing %s", e.Name(), img)
+				}
+			}
+		}
+	}
+	list, _, err := loadReleases(relFS, newMarkdown())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(list) != dirs || dirs == 0 {
+		t.Errorf("loaded %d releases from %d folders", len(list), dirs)
+	}
+}
+
+func TestReleasePages(t *testing.T) {
+	srv, _ := newTestServer(t, nil)
+	const id = "003-css-cache-busting"
+
+	status, _, body := get(t, srv, "/releases")
+	if status != 200 {
+		t.Fatalf("/releases: status = %d", status)
+	}
+	for _, want := range []string{
+		"<h1>Release notes</h1>",
+		`<a href="/releases/` + id + `/">`,
+		`<a href="/releases/001-initial/">Add Markdown blog server</a>`,
+		"#003 · version 0.2.1",
+	} {
+		if !strings.Contains(body, want) {
+			t.Errorf("/releases: missing %q", want)
+		}
+	}
+	if strings.Index(body, "003-css") > strings.Index(body, "001-initial") {
+		t.Error("/releases: newest release should be listed first")
+	}
+
+	status, _, body = get(t, srv, "/releases/"+id+"/")
+	if status != 200 {
+		t.Fatalf("release page: status = %d", status)
+	}
+	for _, want := range []string{
+		"<h1>Add content hash to stylesheet URL; start pull-releases records</h1>",
+		`<span class="meta">Release #003</span>`,
+		`<img src="desktop.png" alt="Desktop">`,
+		`<a href="/releases">`,
+	} {
+		if !strings.Contains(body, want) {
+			t.Errorf("release page: missing %q", want)
+		}
+	}
+
+	status, h, img := get(t, srv, "/releases/"+id+"/desktop.png")
+	if status != 200 || h.Get("Content-Type") != "image/png" || !strings.HasPrefix(img, "\x89PNG") {
+		t.Errorf("screenshot: status = %d, type = %q", status, h.Get("Content-Type"))
+	}
+
+	for _, path := range []string{
+		"/releases/nope/",
+		"/releases/nope",
+		"/releases/" + id + "/README.md",
+		"/releases/" + id + "/missing.png",
+		"/releases/" + id + "/..%2f..%2fmain.go",
+		"/releases/README.md",
+	} {
+		if status, _, _ := get(t, srv, path); status != 404 {
+			t.Errorf("%s: status = %d, want 404", path, status)
+		}
+	}
+}
+
+func TestReleaseRedirect(t *testing.T) {
+	srv, _ := newTestServer(t, nil)
+	client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+	resp, err := client.Get(srv.URL + "/releases/001-initial")
+	if err != nil {
+		t.Fatal(err)
+	}
+	resp.Body.Close()
+	if resp.StatusCode != 301 || resp.Header.Get("Location") != "/releases/001-initial/" {
+		t.Errorf("status = %d, Location = %q", resp.StatusCode, resp.Header.Get("Location"))
+	}
+}
+
+func TestAboutLinksToReleases(t *testing.T) {
+	srv, _ := newTestServer(t, nil)
+	_, _, body := get(t, srv, "/about")
+	if !strings.Contains(body, "<dt>Release notes</dt>") || !strings.Contains(body, `<a href="/releases">`) {
+		t.Errorf("About page has no release notes link:\n%s", body)
+	}
+}

+ 5 - 1
render.go

@@ -69,6 +69,10 @@ type pageData struct {
 	// About page only.
 	Version, Commit string
 	About           template.HTML
+
+	// Release notes pages (and the About page's link to them).
+	Releases []*Release
+	Release  *Release
 }
 
 // loadTemplates parses each page template together with the shared layout.
@@ -82,7 +86,7 @@ func loadTemplates() (map[string]*template.Template, error) {
 		return nil, err
 	}
 	pages := map[string]*template.Template{}
-	for _, name := range []string{"index", "post", "about", "error"} {
+	for _, name := range []string{"index", "post", "about", "releases", "release", "error"} {
 		t, err := base.Clone()
 		if err != nil {
 			return nil, err

+ 11 - 0
static/style.css

@@ -68,6 +68,17 @@ footer.site a { color: var(--muted); font-size: 0.9rem; }
 .facts dt { color: var(--muted); }
 .facts dd { margin: 0; }
 .facts time { margin-left: 0.5rem; }
+
+.meta { color: var(--muted); font-size: 0.9rem; }
+.post-list .meta { display: block; }
+
+/* Release notes: screenshot tables fill the column instead of scrolling. */
+.release-notes table { display: table; width: 100%; table-layout: fixed; }
+.release-notes td { vertical-align: top; }
+.release-notes td img { display: block; width: 100%; height: auto; }
+/* Desktop (wide) first, mobile (tall) second: size columns so both come out
+   about the same height. */
+.release-notes table:has(img) th:first-child { width: 74%; }
 time { color: var(--muted); font-size: 0.9rem; }
 
 .post-list { list-style: none; padding: 0; margin: 0; }

+ 2 - 0
templates/about.html

@@ -5,6 +5,8 @@
   <dl class="facts">
     <dt>Version</dt>
     <dd>{{.Version}}{{with .Commit}} <span class="muted">({{.}})</span>{{end}}</dd>
+    <dt>Release notes</dt>
+    <dd><a href="/releases">{{len .Releases}} releases</a></dd>
     <dt>Posts</dt>
     <dd>{{len .Posts}}</dd>
     {{with .Posts}}{{with index . 0}}

+ 13 - 0
templates/release.html

@@ -0,0 +1,13 @@
+{{define "title"}}{{.Release.Title}} · Release notes · {{.Site}}{{end}}
+{{define "content"}}
+<article class="release-notes">
+  <h1>{{.Release.Title}}</h1>
+  <span class="meta">Release #{{.Release.Number}}</span>
+  <div class="body">
+{{.Release.HTML}}
+  </div>
+</article>
+<nav class="pager">
+  <a href="/releases">← All release notes</a>
+</nav>
+{{end}}

+ 16 - 0
templates/releases.html

@@ -0,0 +1,16 @@
+{{define "title"}}Release notes · {{.Site}}{{end}}
+{{define "content"}}
+<h1>Release notes</h1>
+{{if .Releases}}
+<ul class="post-list">
+{{range .Releases}}
+  <li>
+    <a href="/releases/{{.ID}}/">{{.Title}}</a>
+    <span class="meta">#{{.Number}}{{with .Version}} · version {{.}}{{end}}</span>
+  </li>
+{{end}}
+</ul>
+{{else}}
+<p>No releases yet.</p>
+{{end}}
+{{end}}

+ 1 - 1
version.go

@@ -4,7 +4,7 @@ import "runtime/debug"
 
 // version is the release number shown on the About page. Bump it in the PR
 // that makes a user-visible change.
-const version = "0.2.1"
+const version = "0.3.0"
 
 // buildCommit returns the short git revision that `go build` stamped into the
 // binary, with "-dirty" if the tree had uncommitted changes, or "" if the