diff --git a/docs/superpowers/plans/2026-07-15-wave-1-modernization.md b/docs/superpowers/plans/2026-07-15-wave-1-modernization.md new file mode 100644 index 0000000..bd7399e --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-wave-1-modernization.md @@ -0,0 +1,1306 @@ +# Wave 1 Modernization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Establish a supported Go 1.26 baseline with current Fiber v2 and pgx v5 dependencies, local PostgreSQL infrastructure, build-only CI, a reproducible container image, and focused regression tests without changing application behavior or architecture. + +**Architecture:** Preserve the existing Fiber controllers, global configuration and repository objects, PostgreSQL schema, server-rendered templates, and filesystem upload layout. Apply compatibility changes in reviewable slices: characterize behavior first, update the HTTP/toolchain dependencies, migrate pgx with explicit context propagation, modernize deprecated APIs, then replace development and CI infrastructure. Fiber v3 remains a separate Wave 2 implementation after this plan is fully green. + +**Tech Stack:** Go 1.26.5, Fiber v2.52.14, Fiber HTML template v2.1.3, pgx v5.10.0, PostgreSQL 18.4, Docker Compose, GitHub Actions, Dependabot, and `govulncheck` v1.6.0. + +## Global Constraints + +- Preserve all routes, templates, HTTP statuses, database schema, upload paths, and minimal-JavaScript behavior. +- Preserve existing package boundaries; do not redesign handlers or repositories. +- Do not add database migrations, Redis, a proxy, a queue, or another service. +- Run the application on the host with `go run .` or `make dev`; Compose runs PostgreSQL only. +- Keep Fiber on v2 throughout Wave 1. +- Do not publish container images or deploy from CI. +- Never use a production or shared PostgreSQL database for tests. +- Do not commit `.env`, uploads, binaries, credentials, or generated test artifacts. + +## File Map + +**Create:** + +- `config/config_test.go`: configuration default and parsing characterization. +- `utils/utils_test.go`: post and upload validation boundaries. +- `controllers/threads_controller_test.go`: DB-free HTTP error-path smoke tests. +- `repositories/files_repository_test.go`: pgx v5 integer-array conversion. +- `db/db_test.go`: PostgreSQL undefined-table error classification. +- `.dockerignore`: exclude local and generated state from image context. +- `.github/dependabot.yml`: reviewed Go module and Actions updates. + +**Modify:** + +- `go.mod`, `go.sum`: Go line and dependency graph. +- `main.go`: Fiber template v2 import, startup context, and contextual startup calls. +- `controllers/threads_controller.go`: request context propagation and checked transaction completion. +- `repositories/posts_repository.go`: pgx v5 imports, contextual methods, and rows lifecycle. +- `repositories/files_repository.go`: pgx v5 imports, contextual methods, and native integer-array encoding. +- `repositories/seeds.go`: explicit startup context. +- `db/db.go`: pgx v5 pool, startup context, rows lifecycle, and SQLSTATE handling. +- `build/css.go`, `files/get_files_in_folder.go`, `gateway/connect.go`: replace deprecated `ioutil`; close gateway response bodies. +- `Dockerfile`: multi-stage non-root application image. +- `docker-compose.yml`: PostgreSQL-only local infrastructure. +- `.env.example`: safe local defaults matching Compose. +- `Makefile`: host application and infrastructure commands. +- `.github/workflows/push.yml`: read-only verification CI. +- `README.md`, `AGENTS.md`: supported versions and workflows. + +**Delete:** + +- `.github/workflows/codesee-arch-diagram.yml`: obsolete CodeSee integration. + +--- + +### Task 1: Add Behavior Characterization Tests + +**Files:** + +- Create: `config/config_test.go` +- Create: `utils/utils_test.go` +- Create: `controllers/threads_controller_test.go` + +**Interfaces:** + +- Consumes: existing unexported configuration helpers, `utils.ValidatePost`, `controllers.GetThreads`, and `controllers.GetThread`. +- Produces: regression tests that must pass before and after dependency migration. + +- [ ] **Step 1: Record the pre-change baseline** + +Run: + +```bash +go test ./... +go vet ./... +go build ./... +``` + +Expected: all commands exit 0; packages report no test files. + +- [ ] **Step 2: Add configuration characterization tests** + +Create `config/config_test.go`: + +```go +package config + +import "testing" + +func TestGetValueOrDefaultBoolean(t *testing.T) { + tests := []struct { + name string + value string + defaultValue bool + want bool + }{ + {name: "default true", defaultValue: true, want: true}, + {name: "default false", defaultValue: false, want: false}, + {name: "explicit true", value: "true", want: true}, + {name: "explicit false", value: "false", defaultValue: true, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := getValueOrDefaultBoolean(tt.value, tt.defaultValue); got != tt.want { + t.Fatalf("getValueOrDefaultBoolean() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetValueOrDefaultInt(t *testing.T) { + tests := []struct { + name string + value string + defaultValue int + want int + }{ + {name: "default", defaultValue: 3000, want: 3000}, + {name: "configured", value: "8080", defaultValue: 3000, want: 8080}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := getValueOrDefaultInt(tt.value, tt.defaultValue); got != tt.want { + t.Fatalf("getValueOrDefaultInt() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestDefaultConfigs(t *testing.T) { + for _, key := range []string{ + "ENV", "PORT", "IS_DB_SEEDED", "IS_RATE_LIMITER_ENABLED", + "THREADS_MAX_COUNT", "THREAD_BUMP_LIMIT", "IS_CAPTCHA_ACTIVE", + "GATEWAY_URL", "GATEWAY_API_KEY", "GATEWAY_BOARD_ID", + "GATEWAY_BOARD_URL", "GATEWAY_BOARD_DESCRIPTION", "POSTGRES_URL", + } { + t.Setenv(key, "") + } + + app := getAppConfig() + if app.Env != "release" || app.Port != 3000 || app.IsDbSeeded { + t.Fatalf("unexpected app defaults: %+v", app) + } + if !app.IsRateLimiterEnabled || app.ThreadsMaxCount != 50 || app.ThreadBumpLimit != 500 || !app.IsCaptchaActive { + t.Fatalf("unexpected feature defaults: %+v", app) + } + if db := getDbConfig(); db.Url != "postgresql://localhost/micrach" { + t.Fatalf("unexpected database default: %+v", db) + } +} +``` + +- [ ] **Step 3: Add post and upload validation tests** + +Create `utils/utils_test.go`: + +```go +package utils + +import ( + "mime/multipart" + "net/textproto" + "strings" + "testing" + + "micrach/repositories" +) + +func fileHeader(contentType string, size int64) *multipart.FileHeader { + header := make(textproto.MIMEHeader) + header.Set("Content-Type", contentType) + return &multipart.FileHeader{Filename: "upload", Header: header, Size: size} +} + +func TestValidatePost(t *testing.T) { + validPNG := fileHeader("image/png", 1) + validJPEG := fileHeader("image/jpeg", FILE_SIZE_IN_BYTES) + + tests := []struct { + name string + title string + text string + files []*multipart.FileHeader + want string + }{ + {name: "text only", text: "hello"}, + {name: "file only", files: []*multipart.FileHeader{validPNG}}, + {name: "empty", want: repositories.InvalidTextOrFilesErrorMessage}, + {name: "title over limit", title: strings.Repeat("я", 101), text: "ok", want: repositories.InvalidTitleLengthErrorMessage}, + {name: "text over limit", text: strings.Repeat("я", 1001), want: repositories.InvalidTextLengthErrorMessage}, + {name: "four files", files: []*multipart.FileHeader{validPNG, validPNG, validJPEG, validJPEG}}, + {name: "five files", files: []*multipart.FileHeader{validPNG, validPNG, validPNG, validPNG, validPNG}, want: repositories.InvalidFilesLengthErrorMessage}, + {name: "unsupported content type", files: []*multipart.FileHeader{fileHeader("image/gif", 1)}, want: repositories.InvalidFileExtErrorMessage}, + {name: "size at limit", files: []*multipart.FileHeader{validJPEG}}, + {name: "size over limit", files: []*multipart.FileHeader{fileHeader("image/jpeg", FILE_SIZE_IN_BYTES+1)}, want: repositories.InvalidFileSizeErrorMessage}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ValidatePost(tt.title, tt.text, tt.files); got != tt.want { + t.Fatalf("ValidatePost() = %q, want %q", got, tt.want) + } + }) + } +} +``` + +- [ ] **Step 4: Add DB-free Fiber error-path smoke tests** + +Create `controllers/threads_controller_test.go`: + +```go +package controllers + +import ( + "io" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" +) + +type testViews struct{} + +func (testViews) Load() error { return nil } + +func (testViews) Render(out io.Writer, name string, _ interface{}, _ ...string) error { + _, err := io.WriteString(out, name) + return err +} + +func TestGetThreadsRejectsInvalidPage(t *testing.T) { + app := fiber.New(fiber.Config{Views: testViews{}}) + app.Get("/", GetThreads) + + resp, err := app.Test(httptest.NewRequest("GET", "/?page=0", nil)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("status = %d, want %d", resp.StatusCode, fiber.StatusNotFound) + } +} + +func TestGetThreadRejectsInvalidID(t *testing.T) { + app := fiber.New(fiber.Config{Views: testViews{}}) + app.Get("/:threadID", GetThread) + + resp, err := app.Test(httptest.NewRequest("GET", "/invalid", nil)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("status = %d, want %d", resp.StatusCode, fiber.StatusNotFound) + } +} +``` + +- [ ] **Step 5: Run the new safety net** + +Run: + +```bash +go fmt ./... +go test ./... +go vet ./... +``` + +Expected: all tests pass and vet exits 0. + +- [ ] **Step 6: Commit the safety net** + +```bash +git add config/config_test.go utils/utils_test.go controllers/threads_controller_test.go +git commit -m "test: characterize legacy behavior" +``` + +--- + +### Task 2: Upgrade Go, Fiber v2, and Non-Database Dependencies + +**Files:** + +- Modify: `go.mod` +- Modify: `go.sum` +- Modify: `main.go` + +**Interfaces:** + +- Consumes: characterization tests from Task 1. +- Produces: Go 1.26 module baseline using Fiber v2.52.14 and HTML template v2.1.3; no route or handler API change. + +- [ ] **Step 1: Set the module Go line** + +Run: + +```bash +go mod edit -go=1.26.0 +go mod edit -toolchain=go1.26.5 +``` + +Expected: `go.mod` contains: + +```text +go 1.26.0 + +toolchain go1.26.5 +``` + +- [ ] **Step 2: Upgrade direct non-database dependencies deliberately** + +Run: + +```bash +go get github.com/gofiber/fiber/v2@v2.52.14 +go get github.com/gofiber/template/html/v2@v2.1.3 +go get github.com/dchest/captcha@v1.1.0 +go get github.com/joho/godotenv@v1.5.1 +go get github.com/disintegration/imaging@v1.6.2 +``` + +Expected: each command exits 0; Fiber remains on major version 2. + +- [ ] **Step 3: Switch the template import to its current module path** + +In `main.go`, replace: + +```go +"github.com/gofiber/template/html" +``` + +with: + +```go +"github.com/gofiber/template/html/v2" +``` + +Keep initialization unchanged: + +```go +engine := html.New("./templates", ".html") +``` + +- [ ] **Step 4: Normalize the module graph** + +Run: + +```bash +go mod tidy +go list -m all +``` + +Expected direct requirements: + +```text +github.com/dchest/captcha v1.1.0 +github.com/disintegration/imaging v1.6.2 +github.com/gofiber/fiber/v2 v2.52.14 +github.com/gofiber/template/html/v2 v2.1.3 +github.com/joho/godotenv v1.5.1 +``` + +The old root `github.com/gofiber/template` requirement must be absent. + +- [ ] **Step 5: Verify behavior on the new HTTP dependency baseline** + +Run: + +```bash +go test ./... +go vet ./... +go build ./... +``` + +Expected: all commands exit 0. + +- [ ] **Step 6: Commit the toolchain and Fiber v2 baseline** + +```bash +git add go.mod go.sum main.go +git commit -m "build: update Go and Fiber v2" +``` + +--- + +### Task 3: Migrate pgx v4 to v5 and Propagate Contexts + +**Files:** + +- Create: `repositories/files_repository_test.go` +- Create: `db/db_test.go` +- Modify: `go.mod` +- Modify: `go.sum` +- Modify: `main.go` +- Modify: `controllers/threads_controller.go` +- Modify: `repositories/posts_repository.go` +- Modify: `repositories/files_repository.go` +- Modify: `repositories/seeds.go` +- Modify: `db/db.go` + +**Interfaces:** + +- Consumes: `fiber.Ctx.UserContext() context.Context`; startup uses `context.Background()`. +- Produces: repository and DB operations accepting `context.Context`; pgx v5 transaction types; `toInt32s([]int) []int32` for PostgreSQL `integer[]` parameters. + +- [ ] **Step 1: Write the integer-array conversion test** + +Create `repositories/files_repository_test.go`: + +```go +package repositories + +import ( + "reflect" + "testing" +) + +func TestToInt32s(t *testing.T) { + tests := []struct { + name string + in []int + want []int32 + }{ + {name: "nil", in: nil, want: nil}, + {name: "empty", in: []int{}, want: []int32{}}, + {name: "values", in: []int{1, 2, 2147483647}, want: []int32{1, 2, 2147483647}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := toInt32s(tt.in); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("toInt32s() = %#v, want %#v", got, tt.want) + } + }) + } +} +``` + +- [ ] **Step 2: Run the focused test and confirm the missing helper** + +Run: + +```bash +go test ./repositories -run TestToInt32s -count=1 +``` + +Expected: build failure containing `undefined: toInt32s`. + +- [ ] **Step 3: Add the minimal array conversion helper** + +Add to `repositories/files_repository.go`: + +```go +func toInt32s(values []int) []int32 { + if values == nil { + return nil + } + + result := make([]int32, len(values)) + for i, value := range values { + result[i] = int32(value) + } + return result +} +``` + +Use it directly in the query: + +```go +rows, err := Db.Pool.Query(ctx, sql, toInt32s(postIDs)) +``` + +Do not keep `pgtype.Int4Array` or the standalone `github.com/jackc/pgtype` import. + +- [ ] **Step 4: Write SQLSTATE classification tests** + +Create `db/db_test.go`: + +```go +package db + +import ( + "errors" + "testing" + + "github.com/jackc/pgx/v5/pgconn" +) + +func TestIsUndefinedTable(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {name: "undefined table", err: &pgconn.PgError{Code: "42P01"}, want: true}, + {name: "other postgres error", err: &pgconn.PgError{Code: "23505"}}, + {name: "ordinary error", err: errors.New("boom")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isUndefinedTable(tt.err); got != tt.want { + t.Fatalf("isUndefinedTable() = %v, want %v", got, tt.want) + } + }) + } +} +``` + +- [ ] **Step 5: Upgrade pgx and replace imports** + +Run: + +```bash +go get github.com/jackc/pgx/v5@v5.10.0 +``` + +Replace imports exactly: + +```go +"github.com/jackc/pgx/v4" +"github.com/jackc/pgx/v4/pgxpool" +``` + +with: + +```go +"github.com/jackc/pgx/v5" +"github.com/jackc/pgx/v5/pgconn" +"github.com/jackc/pgx/v5/pgxpool" +``` + +Only import `pgconn` in `db/db.go` and `db/db_test.go`. + +- [ ] **Step 6: Define the contextual DB and repository signatures** + +Change signatures to this exact set: + +```go +// db/db.go +func Init(ctx context.Context) +func Migrate(ctx context.Context) +func runMigration(ctx context.Context, mid int, mname, msql string) +func getDbMigrations(ctx context.Context) MigrationsMap + +// repositories/files_repository.go +func (r *FilesRepository) Create(ctx context.Context, f File) error +func (r *FilesRepository) GetByPostIDs(ctx context.Context, postIDs []int) (map[int][]File, error) +func (r *FilesRepository) CreateInTx(ctx context.Context, tx pgx.Tx, f File) (int, error) + +// repositories/posts_repository.go +func (r *PostsRepository) Get(ctx context.Context, limit, offset int) ([]Post, error) +func (r *PostsRepository) GetThreadsCount(ctx context.Context) (int, error) +func (r *PostsRepository) Create(ctx context.Context, p Post) (int, error) +func (r *PostsRepository) GetThreadByPostID(ctx context.Context, ID int) ([]Post, error) +func (r *PostsRepository) GetIfThreadIsArchived(ctx context.Context, ID int) (bool, error) +func (r *PostsRepository) CreateInTx(ctx context.Context, tx pgx.Tx, p Post) (int, error) +func (r *PostsRepository) GetOldestThreadUpdatedAt(ctx context.Context) (time.Time, error) +func (r *PostsRepository) ArchiveThreadsFrom(ctx context.Context, t time.Time) error +func (r *PostsRepository) GetThreadPostsCount(ctx context.Context, id int) (int, error) +func (r *PostsRepository) BumpThreadInTx(ctx context.Context, tx pgx.Tx, id int) error + +// repositories/seeds.go +func seedDb(ctx context.Context) +func Seed(ctx context.Context) +``` + +Replace every `context.TODO()` passed to pgx with the method's `ctx`. Pass `ctx` +through internal repository calls such as: + +```go +filesMap, err := Files.GetByPostIDs(ctx, postIDs) +``` + +- [ ] **Step 7: Propagate startup and request contexts at call sites** + +In `main.go`: + +```go +ctx := context.Background() +config.Init() +db.Init(ctx) +db.Migrate(ctx) +defer db.Pool.Close() + +if config.App.IsDbSeeded { + repositories.Seed(ctx) +} +``` + +At the beginning of every controller that reaches a repository, add: + +```go +ctx := c.UserContext() +``` + +Pass `ctx` as the first argument to every repository call, `db.Pool.Acquire`, +`conn.Begin`, `tx.Rollback`, and `tx.Commit`. + +- [ ] **Step 8: Make rows lifecycle and migration error handling correct** + +After every successful `Query`, add: + +```go +defer rows.Close() +``` + +Remove pre-iteration `rows.Err()` checks. After each `for rows.Next()` loop, +repository methods returning a slice or map must return the final error: + +```go +if err := rows.Err(); err != nil { + return nil, err +} +``` + +In `getDbMigrations`, which preserves the existing panic-on-startup-error policy, +use this exact check before returning the map: + +```go +if err := rows.Err(); err != nil { + log.Panicln(err) +} +``` + +In `runMigration`, record the migration with `Exec`, not `Query`: + +```go +_, err = Pool.Exec(ctx, `INSERT INTO migrations (id, name) VALUES ($1, $2)`, mid, mname) +if err != nil { + log.Panicln(err) +} +``` + +Replace string matching with SQLSTATE classification: + +```go +func isUndefinedTable(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "42P01" +} +``` + +In `getDbMigrations`, treat only that code as an empty migration map: + +```go +rows, err := Pool.Query(ctx, `SELECT id, name FROM migrations`) +if err != nil { + if isUndefinedTable(err) { + return make(MigrationsMap) + } + log.Panicln(err) +} +defer rows.Close() +``` + +- [ ] **Step 9: Check transaction completion in both write handlers** + +Import `errors` and `github.com/jackc/pgx/v5` in +`controllers/threads_controller.go`. Replace each bare rollback defer with: + +```go +defer func() { + rollbackErr := tx.Rollback(ctx) + if rollbackErr != nil && !errors.Is(rollbackErr, pgx.ErrTxClosed) { + log.Println("error:", rollbackErr) + } +}() +``` + +Replace each unchecked commit with: + +```go +if err := tx.Commit(ctx); err != nil { + log.Println("error:", err) + return c.Status(fiber.StatusInternalServerError).Render("pages/500", nil) +} +``` + +- [ ] **Step 10: Normalize and verify the pgx v5 migration** + +Run: + +```bash +go mod tidy +go fmt ./... +go test ./... +go vet ./... +go build ./... +rg 'jackc/(pgx/v4|pgtype)|context\.TODO' --glob '*.go' +``` + +Expected: build and checks pass; final `rg` returns no matches. + +- [ ] **Step 11: Commit the database driver migration** + +```bash +git add go.mod go.sum main.go controllers/threads_controller.go repositories db +git commit -m "refactor(db): migrate to pgx v5" +``` + +--- + +### Task 4: Replace Deprecated I/O APIs + +**Files:** + +- Modify: `build/css.go` +- Modify: `files/get_files_in_folder.go` +- Modify: `gateway/connect.go` + +**Interfaces:** + +- Consumes: existing file paths, CSS rewrite behavior, and gateway registration request. +- Produces: identical behavior through `os` and `io`, with gateway response bodies closed. + +- [ ] **Step 1: Replace deprecated filesystem calls** + +In `build/css.go`, remove `io/ioutil` and use: + +```go +files, err := os.ReadDir(dir) +files, err := os.ReadDir("static/styles") +htmlTemplate, err := os.ReadFile(htmlTemplatePath) +err = os.WriteFile(htmlTemplatePath, htmlTemplate, 0644) +``` + +In `files/get_files_in_folder.go`, remove `io/ioutil` and use: + +```go +files, err := os.ReadDir(fullFolderPath) +file, err := os.ReadFile(fullFilePath) +``` + +Preserve existing return values, ordering, permissions, and error policy. + +- [ ] **Step 2: Replace gateway body reading and close the response** + +In `gateway/connect.go`, replace `io/ioutil` with `io`, then use: + +```go +resp, err := client.Do(req) +if err != nil { + log.Panicln(err) +} +defer resp.Body.Close() + +body, err := io.ReadAll(resp.Body) +if err != nil { + log.Panicln(err) +} +``` + +- [ ] **Step 3: Verify deprecated APIs are gone** + +Run: + +```bash +go fmt ./... +go test ./... +go vet ./... +go build ./... +rg 'io/ioutil|ioutil\.' --glob '*.go' +``` + +Expected: Go checks pass; final `rg` returns no matches. + +- [ ] **Step 4: Commit the compatibility cleanup** + +```bash +git add build/css.go files/get_files_in_folder.go gateway/connect.go +git commit -m "refactor: replace deprecated I/O APIs" +``` + +--- + +### Task 5: Replace Legacy Compose with Local PostgreSQL + +**Files:** + +- Modify: `docker-compose.yml` +- Modify: `.env.example` +- Modify: `Makefile` + +**Interfaces:** + +- Consumes: application `POSTGRES_URL` configuration. +- Produces: PostgreSQL at `localhost:5432`, database/user/password `micrach`, persistent volume `micrach-postgres`, and host-run development commands. + +- [ ] **Step 1: Replace Compose with the PostgreSQL-only definition** + +Replace `docker-compose.yml` with: + +```yaml +services: + db: + image: postgres:18.4-alpine3.23 + environment: + POSTGRES_DB: micrach + POSTGRES_USER: micrach + POSTGRES_PASSWORD: micrach + ports: + - "5432:5432" + volumes: + - micrach-postgres:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U micrach -d micrach"] + interval: 2s + timeout: 5s + retries: 15 + start_period: 5s + +volumes: + micrach-postgres: +``` + +The `/var/lib/postgresql` mount intentionally follows the PostgreSQL 18 image +layout. + +- [ ] **Step 2: Make local environment defaults safe and compatible** + +Replace `.env.example` with: + +```dotenv +ENV=development +PORT=3000 +IS_DB_SEEDED=false +IS_RATE_LIMITER_ENABLED=true +THREADS_MAX_COUNT=50 +POSTGRES_URL=postgres://micrach:micrach@localhost:5432/micrach?pool_max_conns=5 +THREAD_BUMP_LIMIT=500 +IS_CAPTCHA_ACTIVE=true +GATEWAY_URL= +GATEWAY_API_KEY= +GATEWAY_BOARD_ID= +GATEWAY_BOARD_URL= +GATEWAY_BOARD_DESCRIPTION= +``` + +- [ ] **Step 3: Add explicit host and infrastructure commands** + +Replace `Makefile` with: + +```make +.PHONY: run dev infra-up infra-down + +run: + go run . + +dev: + nodemon --exec go run . --signal SIGTERM + +infra-up: + docker compose up -d db + +infra-down: + docker compose down +``` + +- [ ] **Step 4: Validate Compose syntax and database health** + +Run: + +```bash +docker compose config +docker compose up -d db +docker compose ps +``` + +Expected: configuration is valid; `db` transitions to `healthy`. + +- [ ] **Step 5: Verify clean startup against disposable local data** + +Run with a copied local environment: + +```bash +if test ! -e .env; then cp .env.example .env; fi +go run . +``` + +Expected: logs report database and migrations online; application listens on +port 3000. Stop the process after startup verification. An existing `.env` is +never overwritten or removed, and `.env` is never staged. + +- [ ] **Step 6: Commit local infrastructure** + +```bash +git add docker-compose.yml .env.example Makefile +git commit -m "build: add local PostgreSQL compose" +``` + +--- + +### Task 6: Build a Multi-Stage Non-Root Image + +**Files:** + +- Modify: `Dockerfile` +- Create: `.dockerignore` + +**Interfaces:** + +- Consumes: Go module, templates, static assets, migrations, and writable `/app/uploads`. +- Produces: local `micrach:wave1` Linux image; no registry interaction. + +- [ ] **Step 1: Replace the Dockerfile** + +Replace `Dockerfile` with: + +```dockerfile +FROM golang:1.26.5-alpine3.24 AS build + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/micrach . + +FROM alpine:3.24.1 + +RUN apk add --no-cache ca-certificates \ + && addgroup -S micrach \ + && adduser -S -G micrach micrach \ + && mkdir -p /app/uploads \ + && chown -R micrach:micrach /app + +WORKDIR /app +COPY --from=build /out/micrach ./micrach +COPY --chown=micrach:micrach templates/ templates/ +COPY --chown=micrach:micrach static/ static/ +COPY --chown=micrach:micrach migrations/ migrations/ + +USER micrach +ENTRYPOINT ["./micrach"] +``` + +- [ ] **Step 2: Exclude local state from build context** + +Create `.dockerignore`: + +```text +.git +.github +.env +.env.* +!.env.example +docs +tests +uploads +micrach +*.out +*.test +``` + +- [ ] **Step 3: Build and inspect the image** + +Run: + +```bash +docker build --tag micrach:wave1 . +docker image inspect micrach:wave1 --format '{{.Config.User}} {{json .Config.Entrypoint}}' +``` + +Expected inspect output: + +```text +micrach ["./micrach"] +``` + +- [ ] **Step 4: Confirm upload storage is writable by the runtime user** + +Run: + +```bash +docker run --rm --entrypoint sh micrach:wave1 -c 'touch /app/uploads/write-test && test -f /app/uploads/write-test' +``` + +Expected: exit 0. + +- [ ] **Step 5: Commit the image build** + +```bash +git add Dockerfile .dockerignore +git commit -m "build: add multi-stage image" +``` + +--- + +### Task 7: Replace Deployment Automation with Verification CI + +**Files:** + +- Modify: `.github/workflows/push.yml` +- Delete: `.github/workflows/codesee-arch-diagram.yml` +- Create: `.github/dependabot.yml` + +**Interfaces:** + +- Consumes: `go.mod`, `go.sum`, Dockerfile, and repository tests. +- Produces: read-only checks for pushes and pull requests; reviewed dependency update pull requests. + +- [ ] **Step 1: Replace the workflow with read-only CI** + +Replace `.github/workflows/push.yml` with: + +```yaml +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.26.5" + cache: true + cache-dependency-path: go.sum + + - name: Verify formatting + run: | + files="$(gofmt -l .)" + test -z "$files" || { printf '%s\n' "$files"; exit 1; } + + - name: Test + run: go test ./... + + - name: Vet + run: go vet ./... + + - name: Build + run: go build ./... + + - name: Build Linux binary + run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /tmp/micrach-linux-amd64 . + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@v1.6.0 + + - name: Scan reachable vulnerabilities + run: govulncheck ./... + + - name: Build container image + run: docker build --tag micrach:ci . +``` + +Do not add registry login, image push, artifact upload, SSH, environment secrets, +Swarm, or deployment steps. + +- [ ] **Step 2: Remove CodeSee** + +Delete: + +```text +.github/workflows/codesee-arch-diagram.yml +``` + +- [ ] **Step 3: Add Dependabot** + +Create `.github/dependabot.yml`: + +```yaml +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 +``` + +- [ ] **Step 4: Validate workflow syntax and execute its commands locally** + +Run: + +```bash +test -z "$(gofmt -l .)" +go test ./... +go vet ./... +go build ./... +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /tmp/micrach-linux-amd64 . +go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... +docker build --tag micrach:ci . +``` + +Expected: every command exits 0. If `govulncheck` reports a reachable +vulnerability, upgrade or document the exact blocking dependency before this +task can pass; do not suppress findings generically. + +- [ ] **Step 5: Commit CI and dependency automation** + +```bash +git add .github +git commit -m "ci: replace legacy deployment workflow" +``` + +--- + +### Task 8: Align Documentation and Agent Guidance + +**Files:** + +- Modify: `README.md` +- Modify: `AGENTS.md` + +**Interfaces:** + +- Consumes: completed Wave 1 commands and versions. +- Produces: one accurate setup and verification path for humans and coding agents. + +- [ ] **Step 1: Update README stack and prerequisites** + +State these exact baselines: + +```markdown +- Go 1.26 +- Fiber v2 +- PostgreSQL 18 for local development +``` + +Remove claims that CI uses Go 1.17 and that Docker requires a prebuilt binary. + +- [ ] **Step 2: Replace local setup with the host-run workflow** + +Document this command sequence: + +```sh +cp .env.example .env +docker compose up -d db +go run . +``` + +Document automatic reload separately: + +```sh +make dev +``` + +State that Compose runs PostgreSQL only and that nodemon is required by +`make dev`. + +- [ ] **Step 3: Replace container and CI documentation** + +Document local image verification: + +```sh +docker build -t micrach:local . +``` + +State that CI checks formatting, tests, vet, native and Linux builds, +reachable vulnerabilities, and Docker image construction. State explicitly +that CI does not publish or deploy images. + +- [ ] **Step 4: Update AGENTS guidance** + +Replace stale Go 1.15/1.17 constraints with Go 1.26. Replace the production +Docker and Swarm description with: + +```markdown +- `Dockerfile` builds the application in a pinned Go builder stage and runs it + as a non-root user in a small runtime image. +- `docker-compose.yml` starts local PostgreSQL only; run micrach on the host. +- CI verifies the project and container build but does not publish or deploy. +``` + +Keep all existing architecture, upload, migration, testing, and secret-handling +rules that remain accurate. + +- [ ] **Step 5: Verify documentation** + +Run: + +```bash +rg -n 'Go 1\.15|Go 1\.17|prebuilt|Swarm|Traefik|docker\.pkg\.github|ghcr\.io' README.md AGENTS.md .github Dockerfile docker-compose.yml +git diff --check +``` + +Expected: no stale deployment or toolchain matches; `git diff --check` exits 0. + +- [ ] **Step 6: Commit documentation** + +```bash +git add README.md AGENTS.md +git commit -m "docs: document modern development workflow" +``` + +--- + +### Task 9: Run the Wave 1 Acceptance Gate + +**Files:** + +- Verify only; modify a file only to fix a concrete failing check within Wave 1 scope. + +**Interfaces:** + +- Consumes: all Wave 1 tasks. +- Produces: fresh evidence that Wave 1 is safe to merge and that Wave 2 may be planned separately. + +- [ ] **Step 1: Verify repository hygiene** + +Run: + +```bash +git status --short +git diff --check +git ls-files .env uploads micrach '*.out' '*.test' +``` + +Expected: clean worktree after task commits; no forbidden local artifacts are +tracked. + +- [ ] **Step 2: Run the full Go gate** + +Run: + +```bash +go fmt ./... +test -z "$(gofmt -l .)" +go mod tidy +git diff --exit-code -- go.mod go.sum +go test ./... +go vet ./... +go build ./... +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /tmp/micrach-linux-amd64 . +go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... +``` + +Expected: every command exits 0; module files remain unchanged after tidy; +no reachable vulnerability remains unhandled. + +- [ ] **Step 3: Run the container and infrastructure gate** + +Run: + +```bash +docker compose config +docker compose up -d db +docker compose ps +docker build --tag micrach:wave1 . +docker run --rm --entrypoint sh micrach:wave1 -c 'touch /app/uploads/write-test && test -f /app/uploads/write-test' +``` + +Expected: PostgreSQL is healthy, the image builds, and the runtime user can +write uploads. + +- [ ] **Step 4: Run manual application smoke tests** + +With `.env` copied from `.env.example`, run `go run .` and verify: + +```text +GET / catalog renders +GET /?page=0 404 renders +POST / valid thread redirects +GET /:threadID thread renders +POST /:threadID valid reply redirects with fragment +GET /captcha/:captchaID PNG renders when CAPTCHA is enabled +``` + +Also verify JPEG and PNG upload limits, thumbnail creation, pagination, rate +limiting, `sage`, bump limit, and archival behavior. Inspect catalog and thread +pages at narrow and wide viewport widths; Wave 1 must introduce no visual +change. + +- [ ] **Step 5: Remove disposable local state** + +Run: + +```bash +docker compose down +rm -f /tmp/micrach-linux-amd64 +git status --short --branch +``` + +Expected: no generated or local-only file appears in Git status. Leave any +pre-existing or locally created ignored `.env` untouched. + +## Wave 2 Entry Gate + +Do not implement Fiber v3 in this plan. Start a separate spec-derived plan only +after Task 9 passes. That follow-up must recheck the current Fiber v3, template +engine, and Go requirements through Context7 and official release notes, then +cover handler context signatures, static middleware, form parsing, redirects, +CAPTCHA, uploads, pagination, rate limiting, `sage`, archival, and responsive +smoke tests. Architecture cleanup remains excluded.