micrach/docs/superpowers/plans/2026-07-15-wave-1-modernization.md
2026-07-15 22:51:05 +04:00

1307 lines
32 KiB
Markdown

# 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.