Files
summercms/.planning/phases/11.2-ready-to-share-summercms-io-website-and-newsletter-plugin/11.2-02-PLAN.md

69 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, assumption_delta_decision, specless_probe_fallback, user_setup, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements assumption_delta_decision specless_probe_fallback user_setup estimate must_haves
11.2-ready-to-share-summercms-io-website-and-newsletter-plugin 02 execute 2
11.2-01
README.md
docs/setup/installation.md
internal/docsite/load.go
internal/docsite/docsite.go
internal/docsite/emit.go
internal/docsite/theme/templates/header.html
internal/docsite/theme/assets/site.css
internal/docsite/load_test.go
internal/docsite/theme_test.go
cmd/summer/docs.go
cmd/summer/docs_test.go
docs/console/utilities.md
../sm-summercmsio-app/plugins/golem15/summercms/go.mod
../sm-summercmsio-app/plugins/golem15/summercms/go.sum
../sm-summercmsio-app/plugins/golem15/summercms/plugin.go
../sm-summercmsio-app/plugins/golem15/summercms/static.go
../sm-summercmsio-app/plugins/golem15/summercms/smoke_test.go
../sm-summercmsio-app/plugins/golem15/summercms/links_test.go
../sm-summercmsio-app/plugins/golem15/summercms/public/README.md
../sm-summercmsio-app/plugins/golem15/summercms/.gitignore
../sm-summercmsio-app/plugins/golem15/summercms/README.md
../sm-summercmsio-app/go.mod
../sm-summercmsio-app/go.sum
../sm-summercmsio-app/go.work
../sm-summercmsio-app/summer.yaml
../sm-summercmsio-app/main.go
../sm-summercmsio-app/plugins.gen.go
../sm-summercmsio-app/config/app.yaml
../sm-summercmsio-app/config/http.yaml
../sm-summercmsio-app/config/storage.yaml
../sm-summercmsio-app/config/queue.yaml
../sm-summercmsio-app/config/database.yaml
../sm-summercmsio-app/.gitignore
../sm-summercmsio-app/.gitmodules
../sm-summercmsio-app/scripts/build.sh
../sm-summercmsio-app/scripts/smoke.sh
../sm-summercmsio-app/scripts/check-deploy.sh
../sm-summercmsio-app/terminal_check_test.go
../sm-summercmsio-app/README.md
../sm-summercmsio-app/DEPLOY.md
../sm-summercmsio-app/deploy/nginx/summercms.io.conf
../sm-summercmsio-app/deploy/supervisor/summercms-io.conf
../sm-summercmsio-app/deploy/env.example
../sm-summercmsio-app/deploy/rollback/under-construction/index.html
../sm-summercmsio-app/deploy/rollback/under-construction/logo.png
false
no-change skipped: phase has no requirement IDs to probe (visible skip); ROADMAP SC1-SC5 and the D-IDs are the acceptance contract
tokens raw_tokens tasks confidence
200000 200000 5 low
truths artifacts key_links
Per D-01, D-02 and D-43, `sm-summercmsio-app` (module `git.golem15.com/golem15/sm-summercmsio-app`) is a git repository next to summercms.go, holding `vue-summercmsio-app` and `plugins/golem15/summercms` (module `git.golem15.com/golem15/sm-summercmsio-plugin`, plugin ID `golem15.summercms`) as submodules whose URLs are `git@git.golem15.com:golem15/<repo>.git`; creating those remotes is a listed user step.
Per D-03, the app is a go.work workspace (`use . ./plugins/golem15/summercms`) that requires the framework with `replace git.golem15.com/golem15/summercms => ../summercms.go`, the plugin requires it with `=> ../../../../summercms.go`, and `go list ./...` in the app lists only the app's own package.
Per D-05 and SC2, one `summercms-io` binary embeds the Nuxt output and the docs build through `//go:embed all:public` in the site plugin, and `scripts/build.sh` runs nuxt generate, then `summer docs:build --base-url /docs --site-url / --site-label summercms.io`, then the plugin tests, then `summer build` and a linux/amd64 `go build`.
Per D-07 and SC2, responses for `/` and `/docs/` carry no robots-blocking header and no Content-Security-Policy; `/_nuxt/*` (except `/_nuxt/builds/*`) and `/_fonts/*` get `public, max-age=31536000, immutable`; every other file, including `/docs/assets/*`, gets `no-cache` with a strong ETag, and a matching If-None-Match returns 304.
Per D-47, `GET /docs/<p>` answers 301 to `/docs/<p>.html` when that page exists, built only from the cleaned path; `GET /docs` answers 301 to `/docs/`; an unknown path gets its tree's `404.html` with status 404, and any dot-segment path (for example `/docs/.summer-docs`) is 404.
Per D-24, D-27 and D-29, the binary boots through the stock `serve` command against PostgreSQL 15 with `queue.work_in_serve: false`, numeric `http.body_limits`, a file uploads bucket and secrets only from a server-side `.env`; the site plugin declares no admin controllers, so `/backend` is a 404 and `POST /` is a 405 (smoke script).
Per D-25, the framework's database suites pass against `postgres:15` (output recorded in the SUMMARY) before README.md and docs/setup/installation.md say `PostgreSQL 15 or newer`; if any suite fails on 15 the plan stops and asks the user.
Per D-41 and D-46, `docs/site.yaml` accepts optional `site_url` and `site_label`, `summer docs:build` and `summer docs:serve` accept `--site-url` and `--site-label` overrides, the docs header shows a link back to the main site only when a site URL is set (label: explicit, else the URL host, else `Home`), the framework's own docs output is byte-identical when nothing is set, and `docs/console/utilities.md` documents both keys and flags with a neutral example.
Per D-42, the framework is tagged `v0.1.0` only after the user confirms at a blocking-human checkpoint, at a commit where the D-25 and D-41/D-46 changes are green; the app and the plugin require `git.golem15.com/golem15/summercms v0.1.0`, and the release build takes the docs and the compiled framework from the tag export.
Per SC3 and D-44, `TestLandingLinks` resolves every href and src in the built index.html through the real assembled handler (one 301 followed, final 200), checks every in-page anchor, and requires all ten handoff `/docs/...` targets plus `/docs`; external links are checked anonymously by `TestExternalLinks` at cutover.
Per D-40, `TestTerminalCommands` runs the six commands from `vue-summercmsio-app/app/data/terminal.json` in a temp directory with a temp GOBIN first on PATH and no SUMMER_* or GOWORK variables, and sees `handled=true`; only the clone URL may be overridden (SUMMERCMS_CLONE_URL) until D-38 makes the repository public, and a drift test proves every command and comment appears in the built page.
Per SC4 and D-27 to D-32, DEPLOY.md and the committed nginx and supervisor configs cover the launch checklist, one-time server setup (system user, Postgres role `summercms` owning `summercms_io`, a 0600 `.env`), local build, rsync upload, migrate before restart, nginx with certbot TLS, HTTP to HTTPS and www to apex redirects, gzip, proxy headers and denied admin paths, the supervisor program on 127.0.0.1:8095, verification and rollback; `scripts/check-deploy.sh` passes `nginx -t`.
statement verification
Following DEPLOY.md on rome brings the site up at https://summercms.io (manual, at cutover). backstop
path provides contains
../sm-summercmsio-app/plugins/golem15/summercms/plugin.go site plugin: ID, embed, routes for /, /docs and /docs/{path...} go:embed all:public
path provides contains
../sm-summercmsio-app/plugins/golem15/summercms/static.go embedded static handler: resolution, MIME table, cache rules, ETag, 301s, 404s http.ServeContent
path provides contains
../sm-summercmsio-app/plugins/golem15/summercms/links_test.go SC3 link check, D-40 drift guard, D-46 header check, external link check TestLandingLinks
path provides contains
../sm-summercmsio-app/scripts/build.sh D-05/D-28 scripted build, release from the tag and dev docs:build --base-url /docs --site-url / --site-label summercms.io
path provides contains
../sm-summercmsio-app/scripts/smoke.sh boot on postgres:15 and HTTP assertions postgres:15
path provides contains
../sm-summercmsio-app/terminal_check_test.go D-40 verbatim terminal command check TestTerminalCommands
path provides contains
../sm-summercmsio-app/DEPLOY.md SC4 deploy, launch checklist and rollback supervisorctl restart summercms-io
path provides contains
../sm-summercmsio-app/deploy/nginx/summercms.io.conf nginx server blocks proxy_pass http://127.0.0.1:8095
path provides contains
../sm-summercmsio-app/deploy/supervisor/summercms-io.conf supervisor program user=summercms
path provides contains
internal/docsite/load.go Site.SiteURL, Site.SiteLabel, checkSiteURL, siteLabel yaml:"site_url"
path provides contains
internal/docsite/theme/templates/header.html conditional link back to the main site site-link
path provides contains
cmd/summer/docs.go --site-url and --site-label on docs:build and docs:serve site-label
from to via pattern
../sm-summercmsio-app/plugins.gen.go ../sm-summercmsio-app/plugins/golem15/summercms/plugin.go generated blank import registers the plugin through its init sm-summercmsio-plugin
from to via pattern
../sm-summercmsio-app/plugins/golem15/summercms/plugin.go modules/surf router Routes registers a raw group with GET /docs, GET /docs/{path...} and GET / GroupRaw(
from to via pattern
../sm-summercmsio-app/scripts/build.sh ../sm-summercmsio-app/plugins/golem15/summercms/public rsync of .output/public into public/site and docs:build --out public/docs before go build public/(site|docs)
from to via pattern
../sm-summercmsio-app/scripts/build.sh summercms.go tag v0.1.0 git archive of the required tag feeds docs:build and a temporary go.work replace git -C "$FW" archive
from to via pattern
cmd/summer/docs.go internal/docsite/docsite.go docsOptions copies --site-url and --site-label into Options SiteURL
from to via pattern
../sm-summercmsio-app/terminal_check_test.go ../sm-summercmsio-app/vue-summercmsio-app/app/data/terminal.json reads the single command list the page renders app/data/terminal.json
Make summercms.io shippable: the small framework changes the site needs (D-25 PostgreSQL 15, D-41/D-46 docs link back to the site), the `v0.1.0` tag behind a user confirmation (D-42), the `sm-summercmsio-plugin` site plugin that serves the embedded Nuxt build at `/` and the docs at `/docs` with indexable responses and correct cache headers (D-01, D-05, D-07, D-47), the `sm-summercmsio-app` root app with its workspace, config, build and smoke scripts (D-02, D-03, D-24, D-28), the link and terminal-command checks (SC3, D-40, D-44), and DEPLOY.md with nginx, supervisor and rollback (SC4, D-27 to D-32, D-38, D-43).

Purpose: SC2, SC3 and SC4. Plan 11.2-03 adds the full unit-test coverage on top of the interfaces fixed here.

Output: framework commits in summercms.go (two, then the tag), and two new repositories, sm-summercmsio-app and its submodule sm-summercmsio-plugin (the plan 11.2-01 repository becomes the second submodule).

Task count: five tasks including the tag checkpoint, above the usual three, because the user fixed this phase at three plans (CLAUDE.md lean rule) and placed all of this scope in plan 02.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @CLAUDE.md @.planning/phases/11.2-ready-to-share-summercms-io-website-and-newsletter-plugin/11.2-CONTEXT.md @.planning/phases/11.2-ready-to-share-summercms-io-website-and-newsletter-plugin/11.2-RESEARCH.md @.planning/phases/11.2-ready-to-share-summercms-io-website-and-newsletter-plugin/11.2-PATTERNS.md @.planning/phases/11.2-ready-to-share-summercms-io-website-and-newsletter-plugin/11.2-01-SUMMARY.md @modules/boardwalk/boardwalk.go @internal/docsite/serve.go @internal/docsite/load.go @internal/docsite/emit.go @internal/docsite/theme/templates/header.html @cmd/summer/docs.go @examples/hello/plugins/base/plugin.go @../fonoteka.go/go.work @../fonoteka.go/summer.yaml

Paths. Commands run from the summercms.go root (FW). APP = ../sm-summercmsio-app (absolute /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app), PLUG = APP/plugins/golem15/summercms, SITE = APP/vue-summercmsio-app (created by plan 11.2-01; this plan only reads it). Each task names the repository it commits to. Never git add in the meta repository summercms/.

Commits. One logical change per commit, conventional messages, never a co-author tag. In summercms.go, stage only the files a task lists (never git add -A); planning docs are committed separately by the orchestrator.

Tools. A summer binary for the app is installed into a temporary GOBIN, for example GOBIN=$(mktemp -d) go -C <framework tree> install ./cmd/summer; never rely on a summer already on PATH.

Site plugin, package `summercms`, module `git.golem15.com/golem15/sm-summercmsio-plugin` (plan 11.2-03 tests these names; keep them stable):
  • //go:embed all:public into var publicFS embed.FS (the all: prefix is required: Nuxt writes _nuxt/, _fonts/, _i18n/ and _payload.json).
  • type Plugin struct { fsys fs.FS }; a nil fsys means fs.Sub(publicFS, "public"). Methods ID() string (returns the literal "golem15.summercms"), Requires() []string (nil), Register(*backpack.App) error, Boot(*backpack.App) error, Routes(r pact.Router) error. Compile-time assertion var _ pact.HasRoutes = (*Plugin)(nil). func init() { party.Register(&Plugin{}) }.
  • func newHandlers(public fs.FS) (site, docs http.Handler, err error): subtrees site and docs of public; error text summercms: public/site/index.html missing; run scripts/build.sh (or public/docs/...) when a tree has no index.html.
  • type tree struct with fields files map[string]*file, mount string ("/" or "/docs/"), immutable func(name string) bool (nil means never), htmlRedirect bool (true for docs); type file struct { body []byte; etag, ctype string }.
  • func newTree(fsys fs.FS, mount string, immutable func(string) bool, htmlRedirect bool) (*tree, error): loads every regular file whose path has no dot-segment, precomputes ctype and a strong ETag " + first 16 hex chars of sha256 + "; returns errMissingIndex when index.html is absent.
  • func (t *tree) ServeHTTP(w http.ResponseWriter, r *http.Request): rel = r.URL.Path without t.mount; name = path.Clean("/"+rel) without the leading / (empty means index.html); any segment starting with . → not found; exact file → serve; name + "/index.html" → serve; htmlRedirect and name + ".html" exists → 301 to t.mount + name + ".html"; otherwise not found = the tree's 404.html with status 404 (or http.NotFound without one). Serving sets Content-Type (from ctype), Cache-Control (cacheImmutable when t.immutable(name), else cacheNoCache), ETag, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, then calls http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(body)).
  • const cacheImmutable = "public, max-age=31536000, immutable", const cacheNoCache = "no-cache".
  • func siteImmutable(name string) bool: true for _nuxt/ paths except _nuxt/builds/, and for _fonts/ paths.
  • var contentTypes map[string]string and func contentType(name string) string: own table first (.html text/html; charset=utf-8, .css text/css; charset=utf-8, .js and .mjs text/javascript; charset=utf-8, .json application/json, .md text/markdown; charset=utf-8, .txt text/plain; charset=utf-8, .xml and .xsl application/xml; charset=utf-8, .svg image/svg+xml, .png image/png, .webp image/webp, .ico image/x-icon, .woff2 font/woff2, .woff font/woff, .webmanifest application/manifest+json), then mime.TypeByExtension, then application/octet-stream.
  • func redirectTo(location string) http.HandlerFunc: 301 to a constant location.
  • Test helpers in links_test.go: func requireBuild(t *testing.T) (skips unless SUMMERCMS_REQUIRE_BUILD=1; when set, fails if either tree has no index.html), func pageLinks(html []byte) []string, func resolve(t *testing.T, h http.Handler, target string) (status int, final string).

Framework, package internal/docsite and cmd/summer:

  • Site.SiteURL string (yaml:"site_url") and Site.SiteLabel string (yaml:"site_label"); Options.SiteURL and Options.SiteLabel override them when non-empty, as Options.BaseURL overrides base_url.
  • func checkSiteURL(raw string) error: accepts http:// or https:// URLs with a host and no user info, or a path starting with exactly one /; rejects every other value (javascript:, data:, //host, relative paths, whitespace or control characters).
  • func siteLabel(siteURL, label string) string: label (trimmed) when set, else the host of an absolute URL (the parsed URL's Host field, port included when present), else Home.
  • Unexported site fields siteURL, siteLabel; pageView.SiteURL, pageView.SiteLabel filled in baseView (so the 404 page has the link too), never passed through s.url().
  • CLI flags site-url and site-label on docs:build and docs:serve, read in docsOptions.

App, package main in sm-summercmsio-app (test file only, never shipped in the binary):

  • type terminalGroup struct { Comment string \json:"comment"`; Commands []string `json:"commands"` }, func loadTerminal(path string) ([]terminalGroup, error), const terminalCloneURL = "https://git.golem15.com/golem15/summercms", func terminalScript(groups []terminalGroup, cloneURL string) string(commands joined by\n; a non-empty cloneURL replaces only terminalCloneURLinside thegit clonecommand),func terminalEnv(base []string, gobin string) []string(drops everySUMMER_*andGOWORKentry, setsGOBIN=gobin, prefixes PATH` with gobin).

<assumption_delta_decision> Detector signal: pluralization ("a SummerCMS binary that also serves the Phase 11.1 docs"). Primary noun: the docs site stays a self-contained static tree addressed by base_url. Decision: no-change. The site plugin serves two independent embedded trees by path prefix, and site_url is an optional outbound link, not a second identity for the docs; no data model, primary key or contract changes. </assumption_delta_decision>

Task 1: Tracer: one `summercms-io` binary, booted on PostgreSQL 15, serves the Nuxt site at / and the docs at /docs `docker info` exits 0 and `docker image inspect postgres:15` succeeds; `test -f ../sm-summercmsio-app/vue-summercmsio-app/app/data/terminal.json` succeeds (plan 11.2-01 is complete). D-02/D-01 module paths and repo names are referenced by go.mod, go.work, summer.yaml, the generated imports and the submodule URLs; nothing is published in this task (remotes are created at cutover), so a rename now touches many files but needs no migration. They become one-way when pushed at cutover, a step the user already chose in CONTEXT. ../sm-summercmsio-app/plugins/golem15/summercms/go.mod, ../sm-summercmsio-app/plugins/golem15/summercms/go.sum, ../sm-summercmsio-app/plugins/golem15/summercms/plugin.go, ../sm-summercmsio-app/plugins/golem15/summercms/static.go, ../sm-summercmsio-app/plugins/golem15/summercms/smoke_test.go, ../sm-summercmsio-app/plugins/golem15/summercms/public/README.md, ../sm-summercmsio-app/plugins/golem15/summercms/.gitignore, ../sm-summercmsio-app/go.mod, ../sm-summercmsio-app/go.sum, ../sm-summercmsio-app/go.work, ../sm-summercmsio-app/summer.yaml, ../sm-summercmsio-app/main.go, ../sm-summercmsio-app/plugins.gen.go, ../sm-summercmsio-app/config/app.yaml, ../sm-summercmsio-app/config/http.yaml, ../sm-summercmsio-app/config/storage.yaml, ../sm-summercmsio-app/config/queue.yaml, ../sm-summercmsio-app/config/database.yaml, ../sm-summercmsio-app/.gitignore, ../sm-summercmsio-app/.gitmodules, ../sm-summercmsio-app/scripts/build.sh, ../sm-summercmsio-app/scripts/smoke.sh - modules/boardwalk/boardwalk.go lines 24-48 and 132-173 (embed, fs.Sub, content-type table, cache by prefix; the security headers and SPA fallback there are NOT to be copied, D-07) - internal/docsite/serve.go lines 236-290 (public static semantics: dot-segment refusal, dir index.html, 404.html with status 404) - examples/hello/plugins/base/plugin.go (plugin skeleton, init registration) and examples/hello/plugins/greeter/plugin.go lines 59-76 (Routes) - modules/pact/capabilities.go lines 60-80 (Router, GroupRaw, HasRoutes) and modules/surf/router.go lines 350-430 and 460-480 (route registration, recoverBare, body limits) - modules/cabana/http.go lines 64-74 (no admin controllers means no admin routes) - internal/build/scaffold.go lines 104-170 and 318-370 (plugin:add edits go.mod, go.work and summer.yaml; ID read from plugin.go) - ../fonoteka.go/go.work, ../fonoteka.go/go.mod (header and replaces), ../fonoteka.go/summer.yaml, ../fonoteka.go/.gitignore, ../fonoteka.go/config/{app,http,storage,queue,database}.yaml - modules/surf/clientip.go lines 45-70 (http.trusted_proxies format) - .planning/phases/11.2-ready-to-share-summercms-io-website-and-newsletter-plugin/11.2-RESEARCH.md sections "Framework Surface", "Postgres-at-boot config", "Docs Build", "Pattern 2", "Cache-Control by path", "MIME table", "Build script outline", Pitfalls 7-12 Wire one request path through every layer: embedded files → static handler → surf raw group → generated app binary → stock `serve` on Postgres 15 → HTTP.
  1. Plugin repository (writes to sm-summercmsio-plugin): mkdir -p PLUG and git init -b master inside it. go.mod: module git.golem15.com/golem15/sm-summercmsio-plugin, go 1.27.0, require git.golem15.com/golem15/summercms v0.1.0 (D-42; the directory replace resolves it before the tag exists), replace git.golem15.com/golem15/summercms => ../../../../summercms.go (same depth as fonoteka's plugins). .gitignore: /public/site/ and /public/docs/. public/README.md: build.sh fills site/ (Nuxt output) and docs/ (docs build); nothing else may be placed in public/docs, because docs:build refuses to clean a directory without its marker file; the file exists so the embed pattern always matches.
  2. plugin.go exactly as the interfaces block defines it. The plugin implements only the party lifecycle and pact.HasRoutes: it must not implement the pact admin-controllers capability or any other capability, so cabana never activates and no /backend route exists (D-29, T-11.2-07). Routes builds both handlers via newHandlers (returning its error, so serve fails closed with summercms: public/site/index.html missing; run scripts/build.sh on an unbuilt tree) and registers, inside r.GroupRaw("", nil, …): GET /docs → redirectTo("/docs/") (a permanent 301 instead of ServeMux's 307), GET /docs/{path...} → docs tree, GET / → site tree (the least specific pattern, verified conflict-free alongside cabana's patterns).
  3. static.go exactly as the interfaces block defines it (D-07, D-47; Pitfalls 8-11). Copy the ideas of the admin SPA embed handler (fs.FS, own content-type table, cache by path, ServeContent) and of the docsite preview handler (dot refusal, dir index, 404 page), but add no robots-blocking header, no CSP, no frame-deny header, no index-token rewrite and no SPA fallback: the public site wants indexable pages and real 404s. Build the 301 Location only from t.mount plus the cleaned name plus .html, and only when that file is in the map (T-11.2-05). Stdlib only.
  4. smoke_test.go (smoke level; coverage is plan 11.2-03): TestStaticSmoke builds handlers from a fstest.MapFS with site/index.html, site/404.html, docs/index.html, docs/404.html, docs/setup/installation.html and asserts GET / 200 text/html; charset=utf-8, docs GET /docs/setup/installation 301 to /docs/setup/installation.html, and GET /nope 404 with the 404 body. Run go -C PLUG mod tidy, then commit in PLUG as feat: serve the embedded site at / and the docs at /docs.
  5. App repository (writes to sm-summercmsio-app): git init -b master in APP. Register the two existing repositories as submodules without cloning (git reports "Adding existing repo"): git submodule add git@git.golem15.com:golem15/vue-summercmsio-app.git vue-summercmsio-app and git submodule add git@git.golem15.com:golem15/sm-summercmsio-plugin.git plugins/golem15/summercms (D-43; the remotes are created by the user later, see DEPLOY.md). .gitignore: /bin/, /tmp/, *.exe, go.work.sum, .env, /storage/.
  6. go.mod: module git.golem15.com/golem15/sm-summercmsio-app, go 1.27.0, toolchain go1.27.0, replace git.golem15.com/golem15/summercms => ../summercms.go, require git.golem15.com/golem15/summercms v0.1.0. summer.yaml: module: git.golem15.com/golem15/sm-summercmsio-app, binary: summercms-io, plugins: with id: golem15.summercms, module: git.golem15.com/golem15/sm-summercmsio-plugin. Then, with a summer binary installed from summercms.go into a temp GOBIN, run summer plugin:add plugins/golem15/summercms in APP (it adds the plugin require and replace … => ./plugins/golem15/summercms, and writes go.work with use ( . ./plugins/golem15/summercms ); confirm go 1.27.0 and toolchain go1.27.0 as in fonoteka.go/go.work, and that go.work does not use the framework), then go -C APP mod tidy.
  7. config/ (no secrets; Pitfall 12, D-24, D-27): app.yaml (name: summercms-io, debug: false, locale: en, fallback_locale: en, key: "" with the comment "Set SUMMER_APP__KEY to a 32-byte base64 value (key:generate)"); http.yaml (body_limits.default_bytes: 1048576, body_limits.upload_bytes: 1048576, trusted_proxies holding 127.0.0.1/32 because nginx proxies on loopback, using the CIDR list format clientip.go reads); storage.yaml (uploads.bucket_url: "file://./storage/app/uploads", uploads.public_path_prefix: "/storage/uploads"); queue.yaml (work_in_serve: false with a comment that the site has no jobs, plus fonoteka's max_attempts, job_timeout and queues.default: 1); database.yaml (dsn: "" with the comment "Set SUMMER_DATABASE__DSN").
  8. scripts/build.sh (bash, set -euo pipefail, ROOT from BASH_SOURCE as in fonoteka.go/scripts/check-openapi.sh, explicit guards that print build: … to stderr and exit 1). This task implements the dev mode; Task 4 adds release, which needs the tag, so until then any argument other than dev exits 2 with a usage line. Steps: check pnpm, go, rsync, git, tar exist; FW="${SUMMERCMS_FRAMEWORK:-$ROOT/../summercms.go}"; (a) in SITE pnpm install --frozen-lockfile and pnpm run generate, require .output/public/index.html, then rsync -a --delete "$SITE/.output/public/" "$PLUG/public/site/" (never the dist symlink, which embed refuses); (b) git -C "$FW" archive HEAD | tar -x -C "$TMP/fw" (TMP from mktemp with an EXIT trap), install summer from the export into $TMP/bin, run docs:build --base-url /docs --out "$PLUG/public/docs" inside the export, require public/docs/index.html and public/docs/.summer-docs; (c) SUMMERCMS_REQUIRE_BUILD=1 go -C "$PLUG" test ./...; (d) in APP run "$TMP/bin/summer" build (writes main.go and plugins.gen.go), then CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go -C "$ROOT" build -trimpath -o bin/summercms-io .; print build: bin/summercms-io (dev) ready.
  9. scripts/smoke.sh (bash, set -euo pipefail, trap cleanup of the container, the serve process and the temp dir; binary path argument defaulting to bin/summercms-io): start postgres:15 with docker run -d --rm (user summercms, password smoke, database summercms_io, port 127.0.0.1::5432, read back with docker port), wait for pg_isready; copy config/ into a temp work dir and write a .env there with SUMMER_DATABASE__DSN and SUMMER_APP__KEY (32 random bytes, base64); from the work dir run the binary's migrate, then serve --addr 127.0.0.1:${SMOKE_PORT:-18095} in the background and wait for it. Assert with curl: / 200, Content-Type starting text/html, Cache-Control: no-cache, an ETag, and neither an X-Robots-Tag nor a Content-Security-Policy header; the same / with If-None-Match: <etag> is 304; /docs 301 with Location: /docs/; /docs/ 200 and no X-Robots-Tag; /docs/setup/installation 301 to /docs/setup/installation.html, which is 200; the first /_nuxt/*.js referenced by index.html has immutable and text/javascript; the first file under public/site/_fonts/ is font/woff2 and immutable; /missing-page 404; /backend 404; /docs/.summer-docs 404; POST / 405. Print smoke: ok.
  10. Run scripts/build.sh dev and scripts/smoke.sh, then commit in APP (including .gitmodules and both gitlinks) as feat: wire the summercms.io app with build and smoke scripts. ../sm-summercmsio-app/scripts/build.sh dev && ../sm-summercmsio-app/scripts/smoke.sh <fails_when>non-zero exit, a line starting "build:" on stderr, or no "smoke: ok" line</fails_when> go -C ../sm-summercmsio-app vet ./... && go -C ../sm-summercmsio-app/plugins/golem15/summercms vet ./... && go -C ../sm-summercmsio-app/plugins/golem15/summercms test ./... -run '^TestStaticSmoke$' -count=1 -v <fails_when>non-zero exit, a "--- FAIL" line, or no "--- PASS: TestStaticSmoke" line</fails_when> <acceptance_criteria>
    • go -C ../sm-summercmsio-app list ./... prints exactly git.golem15.com/golem15/sm-summercmsio-app (pnpm's dot-directory layout keeps node_modules out of the module).
    • git -C ../sm-summercmsio-app submodule status lists plugins/golem15/summercms and vue-summercmsio-app, and git -C ../sm-summercmsio-app config -f .gitmodules --get-regexp url prints git@git.golem15.com:golem15/sm-summercmsio-plugin.git and git@git.golem15.com:golem15/vue-summercmsio-app.git.
    • grep -n 'go:embed all:public' ../sm-summercmsio-app/plugins/golem15/summercms/plugin.go and grep -n 'return "golem15.summercms"' ../sm-summercmsio-app/plugins/golem15/summercms/plugin.go both find a match.
    • grep -c 'HasAdminControllers' ../sm-summercmsio-app/plugins/golem15/summercms/plugin.go prints 0.
    • Every import path printed by go -C ../sm-summercmsio-app/plugins/golem15/summercms list -f '{{join .Imports "\n"}}' . is either a standard-library path or starts with git.golem15.com/golem15/summercms/modules/ (no new third-party dependency).
    • grep -n 'work_in_serve: false' ../sm-summercmsio-app/config/queue.yaml finds a match, and grep -n 'key: ""' ../sm-summercmsio-app/config/app.yaml finds a match.
    • file ../sm-summercmsio-app/bin/summercms-io reports an ELF 64-bit x86-64 executable. </acceptance_criteria> The app and plugin repositories exist with the submodule layout, the binary embeds both trees and boots on postgres:15 through the stock serve command, and the smoke script proves status codes, content types, cache headers, 301s, 404s and the absence of robots-blocking and CSP headers.
Task 2: Framework for v0.1.0: verified and documented on PostgreSQL 15, and the docs header links back to the main site README.md, docs/setup/installation.md, internal/docsite/load.go, internal/docsite/docsite.go, internal/docsite/emit.go, internal/docsite/theme/templates/header.html, internal/docsite/theme/assets/site.css, internal/docsite/load_test.go, internal/docsite/theme_test.go, cmd/summer/docs.go, cmd/summer/docs_test.go, docs/console/utilities.md - .planning/phases/11.2-ready-to-share-summercms-io-website-and-newsletter-plugin/11.2-RESEARCH.md sections "PostgreSQL 15 (question 3)", "Docs Build (question 2)", "D-41 header change", Open Question 1 - .planning/phases/11.2-ready-to-share-summercms-io-website-and-newsletter-plugin/11.2-CONTEXT.md D-25, D-41, D-46 - README.md lines 10-40, docs/setup/installation.md lines 1-20 - internal/docsite/load.go (Site, ParseSite, load and the base_url override at lines 217-221), internal/docsite/docsite.go (Options, normalize), internal/docsite/emit.go lines 60-95 (pageView, baseView) - internal/docsite/theme/templates/header.html (whole file), internal/docsite/theme/templates/icons.html line 8 (icon-chevron-left), internal/docsite/theme/assets/site.css lines 250-285 (.wordmark, .header-spacer) and its mobile media query - cmd/summer/docs.go (flag lists of docs:build and docs:serve, docsOptions), cmd/summer/docs_test.go (TestDocsTree, TestDocsBuildRealTree) - internal/docsite/load_test.go lines 11-50 (TestParseSite table) and internal/docsite/theme_test.go lines 1-80 (themeTree, buildTheme) - docs/console/utilities.md lines 28-50 (Documentation commands table) Two commits in summercms.go, in this order. Stage only the listed files.

A. D-25, PostgreSQL 15 (no repository edits until the run passes). Export HEAD to a scratch directory, retarget the test image and run the database suites exactly as RESEARCH "How to run on 15 without permanent edits" does: S=$(mktemp -d), git archive HEAD | tar -x -C "$S", replace postgres:16-alpine with postgres:15 in every *.go file under $S with sed, then go -C "$S" test -count=1 -p 4 ./modules/lagoon/... ./modules/cabana/... ./modules/beachcomber/... ./modules/lighthouse/... ./modules/bouncer/... ./modules/conga/... ./docs/examples/blog/..., and confirm with -v output or the testcontainers log that the image was postgres:15. Record the package result lines in the SUMMARY. If any package fails on 15, stop here: do not edit the docs, do not continue to the tag, and return a checkpoint:decision (gate="blocking-human") to the user with the failing output (D-25 says stop and ask; do not work around it and do not upgrade rome). When every package passes, change README.md line 15 to "PostgreSQL 15 or newer for any application that uses the data layer (lagoon)." and line 35 to "Create a database on PostgreSQL 15 or newer:", and docs/setup/installation.md line 14 to "PostgreSQL 15 or newer for any application that uses the data layer." Leave docs/plugins/testing.md and the lagoon and conga READMEs unchanged: they name the test image, not a requirement. Commit as docs: require PostgreSQL 15 or newer (verified on postgres:15).

B. D-41/D-46, the link back to the main site. Before editing, build the real tree from a HEAD export into a scratch directory pre (for the byte-identical check).

  1. load.go: add SiteURL (yaml:"site_url") and SiteLabel (yaml:"site_label") to Site with doc comments (strict decoding needs the fields). In ParseSite, when site_url is set validate it with checkSiteURL and report docsite: site config: site_url must be an http(s) URL with a host or a path starting with a single /; when site_label is set without site_url report docsite: site config: site_label needs site_url; a label that is blank after trimming or holds a line break is docsite: site config: site_label must be one non-empty line. Add checkSiteURL and siteLabel exactly as the interfaces block defines them (net/url for absolute URLs; T-11.2-09).
  2. docsite.go: add Options.SiteURL ("overrides site.yaml site_url when non-empty") and Options.SiteLabel ("overrides site.yaml site_label when non-empty"). In load, next to the base_url override: start from the config values, apply the option overrides, validate an overriding URL with checkSiteURL (error docsite: --site-url: must be an http(s) URL with a host or a path starting with a single /), refuse a label without any site URL (docsite: --site-label needs --site-url or site.yaml site_url), then store s.siteURL and s.siteLabel = siteLabel(s.siteURL, label).
  3. emit.go: add SiteURL and SiteLabel to pageView and set them in baseView from the site fields, not through s.url() (they point at another site).
  4. header.html: append to the end of the wordmark anchor's line a conditional anchor {{if .SiteURL}}<a class="site-link" href="{{.SiteURL}}" aria-label="{{.SiteLabel}}">{{template "icon-chevron-left"}}<span class="site-link-label">{{.SiteLabel}}</span></a>{{end}}, on the same line so the unset output keeps the exact bytes it has today. html/template escapes the attribute.
  5. site.css: a .site-link rule next to .wordmark (inline-flex, centered, small gap, a left margin, the theme's muted text color and the existing accent on hover, 14px), and in the theme's existing narrow-screen media query hide .site-link-label so the search trigger keeps its room (the aria-label keeps the link named).
  6. cmd/summer/docs.go: add {Name: "site-url", Description: "Main site URL linked from the docs header (overrides site.yaml site_url)"} and {Name: "site-label", Description: "Label of the main site link (overrides site.yaml site_label; default: the URL host, or Home)"} to both docs:build and docs:serve, and read both in docsOptions.
  7. docs/console/utilities.md: add --site-url and --site-label to the Flags cells of the docs:build and docs:serve rows, and below the table a short paragraph: docs/site.yaml accepts two optional keys, site_url and site_label; with site_url: https://acme.example/ every page header links back to the main site as "acme.example"; without site_label the label is the URL's host, or Home for a path such as /; the two flags override the keys the way --base-url overrides base_url. Use only the neutral acme.example example (CLAUDE.md: framework docs never name a consuming application). docs/site.yaml itself stays unchanged (D-46).
  8. Smoke tests (coverage is plan 11.2-03): TestParseSite rows for an accepted https://acme.example/, an accepted /, a rejected javascript:alert(1), a rejected //acme.example, and a rejected site_label without site_url; TestSiteLink in theme_test.go builds themeTree three ways and checks index.html and 404.html: no options → no site-link; Options{SiteURL: "/", SiteLabel: "acme.example"} → <a class="site-link" href="/" and acme.example; Options{SiteURL: "https://acme.example/docs"} → label acme.example; Options{SiteURL: "/"} → label Home. TestDocsBuildSiteFlags in cmd/summer/docs_test.go: docs:build --root ../.. --out <tmp> --site-url / --site-label example.org writes class="site-link" href="/" into index.html, and --site-url javascript:alert(1) returns an error containing --site-url.
  9. Byte-identical check: build the working tree with no site flags into a scratch directory post and require diff -r pre post to print nothing; record that in the SUMMARY. Commit as feat(docsite): optional site_url and site_label link back to the main site. go vet ./internal/docsite/... ./cmd/summer && go test ./internal/docsite ./cmd/summer -run '^(TestDocsTree|TestDocsBuildRealTree|TestToolCommandNames|TestParseSite|TestSiteLink|TestDocsBuildSiteFlags)$' -count=1 -v <fails_when>non-zero exit, a "--- FAIL" line, "no tests to run", or fewer than six "--- PASS" lines for the named tests</fails_when> go vet ./... && go test ./internal/docsite ./cmd/summer -count=1 <fails_when>non-zero exit or a "FAIL" line</fails_when> scripts/check-phase11.1.sh --docs && scripts/check-phase11.1.sh --forbidden <fails_when>non-zero exit or a line starting "refuse:"</fails_when> <acceptance_criteria>
    • The SUMMARY lists an ok line for each of lagoon, lagoon/attach, cabana, beachcomber, lighthouse, bouncer, conga and docs/examples/blog run against postgres:15, and no FAIL line.
    • grep -c 'PostgreSQL 15 or newer' README.md prints 2 and grep -c 'PostgreSQL 15 or newer' docs/setup/installation.md prints 1.
    • grep -n 'yaml:"site_url"' internal/docsite/load.go and grep -n 'yaml:"site_label"' internal/docsite/load.go both find a match.
    • grep -c 'site-url\|site-label' cmd/summer/docs.go prints at least 6 (two flags on two commands plus two reads).
    • grep -n 'acme.example' docs/console/utilities.md finds a match, and git diff --stat "$D25_SHA^..$SITE_SHA" -- docs/site.yaml prints nothing, where D25_SHA and SITE_SHA hold the two commit shas of this task recorded in the SUMMARY (the framework's own site.yaml is unchanged, D-46).
    • diff -r of the pre-change and post-change real-tree builds without site flags prints nothing (recorded in the SUMMARY).
    • git log --format=%s "$D25_SHA^..$SITE_SHA" in summercms.go prints exactly the two commit subjects above, and git log --format='%(trailers:key=Co-authored-by,valueonly)' "$D25_SHA^..$SITE_SHA" prints only empty lines. </acceptance_criteria> The database suites are proven on postgres:15 and the docs say 15 or newer; site_url and site_label (keys and flags) add a validated link back to the main site in every docs page header, leaving the framework's own output unchanged; the docs checker stays green.
Task 3: Confirm the v0.1.0 tag (one-way, D-42) Create and push the `v0.1.0` tag of `git.golem15.com/golem15/summercms` now, at the commit produced by Task 2? D-42 requires the user to confirm before the tag is created and pushed. A pushed Go module version tag is cached by module proxies and cannot be reused, so a wrong commit means burning v0.1.0. Before asking, show: the summercms.go HEAD sha and subject (it must be the Task 2 docsite commit, with the D-25 docs commit before it), `git status --porcelain` (must be empty), the Task 2 test results, the D-25 postgres:15 result lines, and `git log --oneline` since the last pushed commit. The release build in Task 4 takes the docs and the compiled framework from this tag, so the page, the docs and the code agree. Plan 11.2-03 adds framework test commits after the tag, which is acceptable because tests do not change v0.1.0 behaviour. Tag and push v0.1.0 now The release build, the embedded docs and DEPLOY.md all reference a real published tag; nothing is left for cutover. One-way: if a defect is found before launch, the fix ships as v0.1.1. Defer the tag to cutover Keeps v0.1.0 unspent until the site is reviewed; Task 4 still proves the release path against a scratch clone carrying a local tag. The real release build waits for the tag; DEPLOY.md lists creating and pushing it as a launch step. Type "tag-now" or "defer-tag". Task 4: Release build from the v0.1.0 tag, with every landing link and every terminal command proven against the built site Task 3 returned "tag-now" or "defer-tag". For "tag-now": `git ls-remote origin` in summercms.go succeeds (SSH access to git.golem15.com). For "defer-tag": `SUMMERCMS_FRAMEWORK` is exported to the scratch clone (step 1) before any verify command below runs, so the same commands exercise the release path. Pushing v0.1.0 publishes a module version that proxies cache forever (D-42); done only after the Task 3 confirmation. ../sm-summercmsio-app/scripts/build.sh, ../sm-summercmsio-app/scripts/smoke.sh, ../sm-summercmsio-app/terminal_check_test.go, ../sm-summercmsio-app/plugins/golem15/summercms/links_test.go - ../sm-summercmsio-app/scripts/build.sh and scripts/smoke.sh (as written in Task 1) - ../sm-summercmsio-app/plugins/golem15/summercms/plugin.go and static.go (as written in Task 1) - ../sm-summercmsio-app/vue-summercmsio-app/app/data/terminal.json and i18n/locales/en.json (the command list and comment texts) - modules/surf/example_test.go lines 114-140 (surf.Assemble with backpack.New(nil)) - modules/bonfire/prompts.go lines 25-35 (non-interactive Confirm returns the default) - .planning/phases/11.2-ready-to-share-summercms-io-website-and-newsletter-plugin/11.2-RESEARCH.md "Pattern 3", "Every link on the page", "D-40: does ./bin/hello greeter:hello need a database? No.", "Build script outline" (the temporary go.work note) 1. Tag (summercms.go). For "tag-now": confirm `git status --porcelain` is empty and HEAD is the Task 2 docsite commit, run `git tag -a v0.1.0 -m "SummerCMS Alpha 0.1"` and `git push origin v0.1.0`, then confirm `git ls-remote --tags origin v0.1.0` prints it. If the push fails on authentication, return a `checkpoint:human-action` asking the user to run the same push. For "defer-tag": create nothing in summercms.go; make a scratch clone (`git clone` of the local summercms.go into a temp dir), add a local `v0.1.0` tag there, and use it through `SUMMERCMS_FRAMEWORK=` for every release-mode run below. Record which path was taken in the SUMMARY. 2. `scripts/build.sh` release mode (now the default when no argument is given; `dev` stays): read the framework version the app requires (`go -C "$ROOT" list -m -f '{{.Version}}' git.golem15.com/golem15/summercms`, which prints `v0.1.0`); require `git -C "$FW" rev-parse -q --verify "refs/tags/$TAG^{commit}"`, else exit 1 with `build: tag $TAG not found in $FW; run 'scripts/build.sh dev' or create the tag (DEPLOY.md)`; `git -C "$FW" archive "$TAG"` into `$TMP/fw`; install summer from that export; run docs:build there with `--base-url /docs --site-url / --site-label summercms.io --out "$PLUG/public/docs"` (D-46; dev mode now passes the same two site flags); run the plugin tests with `SUMMERCMS_REQUIRE_BUILD=1`; run `summer build` in APP, then fail if `git -C "$ROOT" status --porcelain -- main.go plugins.gen.go` shows drift (generated sources must be committed); write `$TMP/go.work` (`go 1.27.0`, `use` the absolute APP and PLUG directories, `replace git.golem15.com/golem15/summercms => $TMP/fw`) and build with `GOWORK="$TMP/go.work" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go -C "$ROOT" build -trimpath -o bin/summercms-io .` so the compiled framework is the tag (go.work replaces override module replaces); print `build: bin/summercms-io (release v0.1.0) ready`. 3. `scripts/smoke.sh`: add the D-46 assertion that `/docs/` contains `class="site-link" href="/"` and the text `summercms.io`. 4. `links_test.go` in PLUG (package `summercms`, stdlib only) with the helpers from the interfaces block: - `TestLandingLinks` (`requireBuild`): assemble the real plugin with `surf.Assemble(backpack.New(nil), []party.Plugin{&Plugin{}})`; collect every `href` and `src` value from `public/site/index.html`, plus the `og:image` content; for `#id` require `id="id"` in the page; for a root-relative path, or an absolute `https://summercms.io/` URL mapped to its path, request it through the handler, follow at most one 301 whose Location must start with `/`, and require a final 200; collect other `http(s)` URLs for the external test. Require that the hrefs include `/docs` and the ten D-44 targets: `/docs/backend/admin-spa`, `/docs/database/models`, `/docs/services/routing`, `/docs/services/jobs`, `/docs/services/realtime`, `/docs/services/search`, `/docs/services/mail`, `/docs/console/introduction`, `/docs/setup/coming-from-wintercms`, `/docs/setup/installation`. - `TestTerminalCommandsInPage` (`requireBuild`; the D-40 drift guard): read `../../../vue-summercmsio-app/app/data/terminal.json` and `../../../vue-summercmsio-app/i18n/locales/en.json` (overridable with `SUMMERCMS_SITE_DIR`) and require every command and every resolved comment text in the built index.html. - `TestDocsHeaderSiteLink` (`requireBuild`): `public/docs/index.html` contains `class="site-link" href="/"` and `summercms.io`. - `TestExternalLinks` (skips unless `SUMMERCMS_CHECK_EXTERNAL=1`): GET each external URL with a fresh client (no cookies, 15 s timeout, redirects followed) and require 200; log each URL with its status. Before D-38 the Source link is expected to fail (private repository); this test runs at cutover. Commit in PLUG as `test: verify the landing links and terminal commands against the built site`. 5. `terminal_check_test.go` in APP (package `main`) with the helpers from the interfaces block. `TestTerminalCommands` skips unless `SUMMERCMS_TERMINAL_CHECK=1`; it loads `vue-summercmsio-app/app/data/terminal.json`, creates a temp work dir and a temp GOBIN, runs `bash -euo pipefail -c ` in the work dir with stdin from /dev/null, `terminalEnv(os.Environ(), gobin)` and a 10-minute context, then requires exit 0 and `handled=true` in the combined output (logged on failure). With `SUMMERCMS_CLONE_URL` unset the commands run verbatim (cutover, after D-38). If a command needs setup that the check cannot provide without changing the page copy, stop and ask the user (D-40) instead of editing terminal.json. 6. Run the release build (for "defer-tag", with `SUMMERCMS_FRAMEWORK` pointing at the scratch clone), then `scripts/smoke.sh`, then `SUMMERCMS_TERMINAL_CHECK=1 SUMMERCMS_CLONE_URL=/media/nvme/dev/golem15/summercms.io/summercms/summercms.go go -C ../sm-summercmsio-app test -run '^TestTerminalCommands$' -count=1 -v .`. Commit in APP (including the updated plugin gitlink) as `feat: release builds from the v0.1.0 tag and the terminal command check`. ../sm-summercmsio-app/scripts/build.sh && ../sm-summercmsio-app/scripts/smoke.sh non-zero exit, a line starting "build:" on stderr, no "(release v0.1.0) ready" line, or no "smoke: ok" line SUMMERCMS_REQUIRE_BUILD=1 go -C ../sm-summercmsio-app/plugins/golem15/summercms test ./... -run '^(TestLandingLinks|TestTerminalCommandsInPage|TestDocsHeaderSiteLink)$' -count=1 -v non-zero exit, a "--- FAIL" or "--- SKIP" line, or fewer than three "--- PASS" lines SUMMERCMS_TERMINAL_CHECK=1 SUMMERCMS_CLONE_URL=/media/nvme/dev/golem15/summercms.io/summercms/summercms.go go -C ../sm-summercmsio-app test -run '^TestTerminalCommands$' -count=1 -v . non-zero exit, a "--- SKIP" line, or no "--- PASS: TestTerminalCommands" line - For "tag-now": `git ls-remote --tags origin v0.1.0` in summercms.go prints one line ending in `refs/tags/v0.1.0`. For "defer-tag": `git tag -l v0.1.0` in summercms.go prints nothing and the SUMMARY names the scratch clone used. - `go version -m ../sm-summercmsio-app/bin/summercms-io | grep 'git.golem15.com/golem15/summercms'` shows version `v0.1.0`. - `grep -n 'docs:build --base-url /docs --site-url / --site-label summercms.io' ../sm-summercmsio-app/scripts/build.sh` finds a match. - `grep -c 'class="site-link" href="/"' ../sm-summercmsio-app/plugins/golem15/summercms/public/docs/index.html` prints 1. - `go -C ../sm-summercmsio-app test ./... -count=1` passes with the gated tests skipped (no env vars set), and `go -C ../sm-summercmsio-app vet ./...` is clean. The release binary is built from the confirmed v0.1.0 tag (or a scratch tag when deferred), every landing link resolves against the built site with redirects followed, the page and the terminal check share one command list, and the six commands run from a fresh shell to handled=true. Task 5: An operator can deploy, verify and roll back summercms.io on rome by following DEPLOY.md ../sm-summercmsio-app/DEPLOY.md, ../sm-summercmsio-app/README.md, ../sm-summercmsio-app/deploy/nginx/summercms.io.conf, ../sm-summercmsio-app/deploy/supervisor/summercms-io.conf, ../sm-summercmsio-app/deploy/env.example, ../sm-summercmsio-app/deploy/rollback/under-construction/index.html, ../sm-summercmsio-app/deploy/rollback/under-construction/logo.png, ../sm-summercmsio-app/scripts/check-deploy.sh, ../sm-summercmsio-app/plugins/golem15/summercms/README.md - .planning/phases/11.2-ready-to-share-summercms-io-website-and-newsletter-plugin/11.2-RESEARCH.md section "Deploy (question 6)" items 1-9, "Live site observations", Assumptions A1-A3, "Security Domain" - .planning/phases/11.2-ready-to-share-summercms-io-website-and-newsletter-plugin/11.2-CONTEXT.md D-27 to D-32, D-38, D-42, D-43 - modules/surf/serve.go lines 20-80 (`serve --addr`, 10 s shutdown on SIGTERM), modules/lagoon/keygen.go (key:generate prints a key), modules/compass/README.md lines 85-100 (config dir and the dotenv file next to it) - ../sm-summercmsio-app/scripts/build.sh and scripts/smoke.sh (as written in Tasks 1 and 4) Write the deploy contract for rome (D-28 to D-32) and the user's launch steps (D-38, D-42, D-43). Values marked [ASSUMED] in RESEARCH are written as defaults with a "check on rome" note.
  1. deploy/nginx/summercms.io.conf (D-29, D-30, D-31): a port-80 server for summercms.io and www.summercms.io answering 301 https://summercms.io$request_uri; a 443 server for www.summercms.io answering 301 to the apex (rome has no 443 www block today, so https://www.summercms.io fails its TLS handshake); the 443 apex server with listen 443 ssl http2, ssl_certificate /etc/letsencrypt/live/summercms.io/fullchain.pem, ssl_certificate_key /etc/letsencrypt/live/summercms.io/privkey.pem, include /etc/letsencrypt/options-ssl-nginx.conf, ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem (comment: copy the lineage lines from the existing block if they differ), gzip on; gzip_vary on; gzip_proxied any; with types text/css text/plain text/xml application/xml application/json text/javascript application/javascript image/svg+xml text/markdown, location ^~ /backend { return 404; }, and location / with limit_except GET HEAD { deny all; }, proxy_pass http://127.0.0.1:8095, proxy_http_version 1.1 and the Host, X-Real-IP, X-Forwarded-For and X-Forwarded-Proto headers.
  2. deploy/supervisor/summercms-io.conf (D-32): [program:summercms-io] with command=/srv/summercms-io/bin/summercms-io serve --addr 127.0.0.1:8095, directory=/srv/summercms-io (config is read relative to the working directory), user=summercms, environment=SUMMER_ENV="production", autostart=true, autorestart=true, startsecs=3, stopsignal=TERM, stopwaitsecs=15, redirect_stderr=true, stdout_logfile=/var/log/supervisor/summercms-io.log with size and backup limits.
  3. deploy/env.example: the two variable names with no secret values (SUMMER_DATABASE__DSN=postgres://summercms:<password>@127.0.0.1:5432/summercms_io?sslmode=disable, SUMMER_APP__KEY=) and a comment that the real file lives at /srv/summercms-io/.env, owner summercms, mode 0600, never in git (T-11.2-08).
  4. Rollback copy of today's site (D-31): download https://summercms.io/ and https://summercms.io/logo.png with curl -fsSL into deploy/rollback/under-construction/index.html and logo.png (the live "Under construction" docroot is one HTML file plus the logo).
  5. DEPLOY.md with these sections:
    • Overview: what runs where (nginx :443 → 127.0.0.1:8095 summercms-io → Postgres 15 summercms_io), the user, port and paths (summercms, 127.0.0.1:8095, /srv/summercms-io/{bin,config,storage,.env}), marked "check on rome" where assumed.
    • Launch checklist (once, before cutover, done by the user): create the three repositories golem15/sm-summercmsio-app, golem15/vue-summercmsio-app and golem15/sm-summercmsio-plugin on git.golem15.com, then in each local repository git remote add origin git@git.golem15.com:golem15/<name>.git and git push -u origin master, submodules first (ssu can push submodules that are ahead) (D-43); make golem15/summercms public (D-38); confirm git ls-remote --tags origin v0.1.0 in summercms.go, or create and push the tag now if it was deferred (D-42); run SUMMERCMS_CHECK_EXTERNAL=1 go -C plugins/golem15/summercms test -run TestExternalLinks -count=1 -v ./... and SUMMERCMS_TERMINAL_CHECK=1 go test -run TestTerminalCommands -count=1 -v . with no clone override; on rome check that port 8095 is free with ss -ltnp.
    • One-time server setup: adduser --system --group --home /srv/summercms-io --shell /usr/sbin/nologin summercms; sudo -u postgres createuser --pwprompt summercms; sudo -u postgres createdb -O summercms -E UTF8 summercms_io (D-27); create the directories; write /srv/summercms-io/.env from deploy/env.example with the DSN and a key printed by ./bin/summercms-io key:generate, chown summercms:summercms and chmod 0600; install the supervisor program and run supervisorctl reread && supervisorctl update.
    • Build (local, D-28): scripts/build.sh (release from v0.1.0; scripts/build.sh dev builds from the framework working tree for previews); the server needs neither Go nor Node.
    • Upload: rsync -av --chmod=F644,D755 config/ rome:/srv/summercms-io/config/ and rsync -av bin/summercms-io rome:/srv/summercms-io/bin/summercms-io.new; .env is never rsynced.
    • Release (every deploy): cd /srv/summercms-io && sudo -u summercms ./bin/summercms-io.new migrate (D-27, before restart; cwd matters), keep the old binary as bin/summercms-io.prev, move .new into place, supervisorctl restart summercms-io, curl -sI http://127.0.0.1:8095/.
    • Cutover (D-31): first save the existing summercms.io server block from rome into deploy/rollback/nginx-under-construction.conf in this repository and commit it (it cannot be read from the build machine), keep the old docroot, then replace the block in place with deploy/nginx/summercms.io.conf and run nginx -t && systemctl reload nginx.
    • Verify after cutover: curl checks for https://summercms.io/ 200 with Cache-Control: no-cache and no robots-blocking header, a /_nuxt/ asset with immutable, /docs/ 200, /docs/setup/installation 301 to .html, /backend 404, POST / 403, http://summercms.io/ 301 to https, https://www.summercms.io/ 301 to the apex; then the external link check against the live site.
    • Rollback: app (mv bin/summercms-io.prev bin/summercms-io && supervisorctl restart summercms-io; 11.2 adds no plugin migrations) and cutover (restore deploy/rollback/nginx-under-construction.conf and the docroot from deploy/rollback/under-construction/, then nginx -t && systemctl reload nginx).
  6. scripts/check-deploy.sh (bash, set -euo pipefail): in a temp prefix, generate a self-signed certificate with openssl, copy the nginx site config with the certbot paths, the options include and the dhparam line replaced by temp equivalents, wrap it in a minimal nginx.conf (events block, an http block with temp *_temp_path entries and pid in the prefix), and run nginx -t -p "$TMP" -c "$TMP/nginx.conf" -e "$TMP/error.log"; parse the supervisor file with python3 configparser and require [program:summercms-io] with command, directory, user, autostart, autorestart, stopsignal; print check-deploy: ok.
  7. README.md in APP: what the app is, the submodule layout and git submodule update --init, scripts/build.sh and dev, the test commands and their environment gates (SUMMERCMS_REQUIRE_BUILD, SUMMERCMS_TERMINAL_CHECK, SUMMERCMS_CLONE_URL, SUMMERCMS_CHECK_EXTERNAL), and a pointer to DEPLOY.md. README.md in PLUG: what it serves (/, /docs, /docs/{path...}), the cache and content-type rules, the 301 for extension-less docs URLs, why it declares no admin controllers, and how public/ is filled. Commit in PLUG as docs: describe the site plugin, then in APP (with the updated plugin gitlink) as docs: add DEPLOY.md with nginx, supervisor and rollback configs. ../sm-summercmsio-app/scripts/check-deploy.sh <fails_when>non-zero exit, "test failed" or "[emerg]" in the nginx output, or no "check-deploy: ok" line</fails_when> At cutover, follow DEPLOY.md on rome from the launch checklist to "Verify after cutover": the site answers at https://summercms.io with the landing page, /docs serves the docs with the "summercms.io" header link, /backend is 404, and https://www.summercms.io redirects to the apex. <acceptance_criteria>
    • grep -n 'location ^~ /backend' ../sm-summercmsio-app/deploy/nginx/summercms.io.conf and grep -n 'limit_except GET HEAD' ../sm-summercmsio-app/deploy/nginx/summercms.io.conf both find a match.
    • grep -n 'user=summercms' ../sm-summercmsio-app/deploy/supervisor/summercms-io.conf and grep -n '127.0.0.1:8095' ../sm-summercmsio-app/deploy/supervisor/summercms-io.conf both find a match.
    • grep -c 'migrate' ../sm-summercmsio-app/DEPLOY.md prints at least 1, and grep -n 'createdb -O summercms' ../sm-summercmsio-app/DEPLOY.md, grep -n 'chmod 0600' ../sm-summercmsio-app/DEPLOY.md and grep -n 'git remote add origin' ../sm-summercmsio-app/DEPLOY.md each find a match.
    • grep -n 'Rollback' ../sm-summercmsio-app/DEPLOY.md finds a match and test -s ../sm-summercmsio-app/deploy/rollback/under-construction/index.html succeeds.
    • git -C ../sm-summercmsio-app ls-files -- .env deploy/.env prints nothing (secrets are never committed). </acceptance_criteria> DEPLOY.md, the nginx and supervisor configs, the env template and the rollback copy cover build, upload, release, cutover, verification and rollback on rome; the nginx config passes nginx -t; the user's launch steps (remotes, public repository, tag, external checks) are listed.

<threat_model>

Trust Boundaries

Boundary Description
internet → nginx (rome) Untrusted HTTP requests; TLS terminates here
nginx → summercms-io on 127.0.0.1:8095 Proxied requests; path and method filtered by nginx
request path → embedded file map Untrusted path selects a file and may trigger a redirect
site.yaml / CLI flags → docs header HTML Configured URL is written into every docs page
operator machine → rome Binary and config upload; secrets stay on the server
summercms.go → module proxy A pushed version tag becomes immutable public state

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-11.2-04 Information disclosure PLUG static.go path resolution high mitigate path.Clean on a fixed root, lookups only in the in-memory map built from the embedded tree, dot-segment paths refused (hides .summer-docs); smoke asserts /docs/.summer-docs 404
T-11.2-05 Spoofing (open redirect) /docs and extension-less docs redirects medium mitigate Location is a constant (/docs/) or t.mount + cleaned name + .html, only when that file exists; never from the raw URL
T-11.2-06 Tampering (MIME sniffing) static.go headers low mitigate Own content-type table before mime, X-Content-Type-Options: nosniff; only build output is served
T-11.2-07 Elevation of privilege admin exposure high mitigate The plugin declares no admin controllers, so cabana never mounts /backend; nginx location ^~ /backend { return 404; }; smoke asserts /backend 404
T-11.2-08 Information disclosure DB password and app key high mitigate Config YAML keeps key and dsn empty; secrets only in /srv/summercms-io/.env (0600, owner summercms), gitignored and never rsynced; deploy/env.example holds names only
T-11.2-09 Tampering (XSS) docs header site_url medium mitigate checkSiteURL allow-list (http/https with host, or a single-slash path) for both the key and the flag; html/template escaping; TestParseSite rejects javascript: and //host
T-11.2-10 Tampering (clickjacking) public pages low accept Static marketing and docs pages with no state-changing actions; the admin CSP is deliberately not reused because it blocks Nuxt's inline scripts (D-07)
T-11.2-11 Information disclosure (transport) nginx high mitigate HTTPS only with certbot certificates, HTTP→HTTPS 301, a 443 www→apex block; the binary listens on loopback only
T-11.2-12 Denial of service nginx and binary medium mitigate limit_except GET HEAD at nginx, GET-only routes (405 otherwise) and in-memory static files in the binary; supervisor restarts on exit
T-11.2-13 Tampering / Repudiation v0.1.0 tag medium mitigate Blocking-human checkpoint before creation and push; annotated tag at a clean, green commit whose sha is shown to the user
T-11.2-14 Elevation of privilege runtime account medium mitigate Dedicated nologin system user, dedicated Postgres role owning only summercms_io (D-27), app on 127.0.0.1
T-11.2-SC Tampering dependency installs high mitigate No new Go module (plugin and app import only stdlib and framework modules, checked by an acceptance criterion); build.sh installs npm packages only with --frozen-lockfile from plan 11.2-01's lockfile
</threat_model>
- summercms.go: `go vet ./... && go test ./internal/docsite ./cmd/summer -count=1` green; `scripts/check-phase11.1.sh --docs && scripts/check-phase11.1.sh --forbidden` green. - APP: `scripts/build.sh` (release) or the scratch-clone equivalent, `scripts/smoke.sh`, `scripts/check-deploy.sh` green; `go -C ../sm-summercmsio-app vet ./... && go -C ../sm-summercmsio-app test ./... -count=1` green. - PLUG: `SUMMERCMS_REQUIRE_BUILD=1 go -C ../sm-summercmsio-app/plugins/golem15/summercms test ./... -count=1` green. - D-40: `TestTerminalCommands` green with the local clone override.

<success_criteria>

  • SC2: one binary embeds and serves / and /docs with indexable responses and the cache rules (smoke and plugin tests).
  • SC3: every link on the page resolves against the built docs (TestLandingLinks); external links are checked at cutover.
  • SC4: scripted build and DEPLOY.md with supervisor and nginx configs (TLS, proxy, gzip); the clean-server bring-up is the cutover UAT item.
  • D-25, D-41, D-42, D-46 framework changes landed before the tag; the tag is created only after confirmation. </success_criteria>

User steps at cutover (D-38, D-42, D-43)

Creating the three remotes and pushing, making golem15/summercms public, pushing v0.1.0 if it was deferred, running the external-link and verbatim terminal checks, saving rome's current server block, and the rome deploy itself. DEPLOY.md's launch checklist lists them; the SUMMARY repeats them.

Artifacts this phase produces

  • Repositories: sm-summercmsio-app (module git.golem15.com/golem15/sm-summercmsio-app, binary summercms-io) and sm-summercmsio-plugin (module git.golem15.com/golem15/sm-summercmsio-plugin, package summercms, plugin ID golem15.summercms) at /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app and its plugins/golem15/summercms; submodule URLs git@git.golem15.com:golem15/vue-summercmsio-app.git and git@git.golem15.com:golem15/sm-summercmsio-plugin.git.
  • Plugin symbols: Plugin (field fsys; methods ID, Requires, Register, Boot, Routes), publicFS, newHandlers, tree (fields files, mount, immutable, htmlRedirect; method ServeHTTP), file (fields body, etag, ctype), newTree, errMissingIndex, siteImmutable, contentTypes, contentType, redirectTo, cacheImmutable, cacheNoCache. Routes GET /docs, GET /docs/{path...}, GET /.
  • Plugin tests: TestStaticSmoke, TestLandingLinks, TestTerminalCommandsInPage, TestDocsHeaderSiteLink, TestExternalLinks; helpers requireBuild, pageLinks, resolve.
  • App test symbols: terminalGroup, loadTerminal, terminalCloneURL, terminalScript, terminalEnv, TestTerminalCommands.
  • Environment variables: SUMMERCMS_FRAMEWORK, SUMMERCMS_REQUIRE_BUILD, SUMMERCMS_TERMINAL_CHECK, SUMMERCMS_CLONE_URL, SUMMERCMS_CHECK_EXTERNAL, SUMMERCMS_SITE_DIR, SMOKE_PORT; runtime SUMMER_DATABASE__DSN, SUMMER_APP__KEY, SUMMER_ENV.
  • App config keys: app.key, http.body_limits.default_bytes, http.body_limits.upload_bytes, http.trusted_proxies, storage.uploads.bucket_url, storage.uploads.public_path_prefix, queue.work_in_serve, database.dsn.
  • Scripts and deploy files: scripts/build.sh (release, dev), scripts/smoke.sh, scripts/check-deploy.sh, DEPLOY.md, deploy/nginx/summercms.io.conf, deploy/supervisor/summercms-io.conf, deploy/env.example, deploy/rollback/under-construction/.
  • Framework: docsite.Site.SiteURL, docsite.Site.SiteLabel, docsite.Options.SiteURL, docsite.Options.SiteLabel, unexported checkSiteURL, siteLabel, pageView.SiteURL, pageView.SiteLabel; site.yaml keys site_url, site_label; CLI flags --site-url, --site-label on summer docs:build and summer docs:serve; CSS classes .site-link, .site-link-label; tests TestSiteLink, TestDocsBuildSiteFlags and new TestParseSite rows; git tag v0.1.0 (after confirmation).
Create `.planning/phases/11.2-ready-to-share-summercms-io-website-and-newsletter-plugin/11.2-02-SUMMARY.md` when done