micrach/docs/superpowers/plans/2026-07-15-wave-1-modernization.md
2026-07-16 12:12:48 +04:00

32 KiB

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:

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:

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:

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:

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:

go fmt ./...
go test ./...
go vet ./...

Expected: all tests pass and vet exits 0.

  • Step 6: Commit the safety net
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:

go mod edit -go=1.26.0
go mod edit -toolchain=go1.26.5

Expected: go.mod contains:

go 1.26.0

toolchain go1.26.5
  • Step 2: Upgrade direct non-database dependencies deliberately

Run:

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:

"github.com/gofiber/template/html"

with:

"github.com/gofiber/template/html/v2"

Keep initialization unchanged:

engine := html.New("./templates", ".html")
  • Step 4: Normalize the module graph

Run:

go mod tidy
go list -m all

Expected direct requirements:

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:

go test ./...
go vet ./...
go build ./...

Expected: all commands exit 0.

  • Step 6: Commit the toolchain and Fiber v2 baseline
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:

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:

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:

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:

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:

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:

go get github.com/jackc/pgx/v5@v5.10.0

Replace imports exactly:

"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"

with:

"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:

// 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:

filesMap, err := Files.GetByPostIDs(ctx, postIDs)
  • Step 7: Propagate startup and request contexts at call sites

In main.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:

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:

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:

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:

if err := rows.Err(); err != nil {
	log.Panicln(err)
}

In runMigration, record the migration with Exec, not Query:

_, 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:

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:

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:

defer func() {
	rollbackErr := tx.Rollback(ctx)
	if rollbackErr != nil && !errors.Is(rollbackErr, pgx.ErrTxClosed) {
		log.Println("error:", rollbackErr)
	}
}()

Replace each unchecked commit with:

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:

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
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:

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:

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:

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:

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
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:

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:

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:

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

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:

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
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:

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:

.git
.github
.env
.env.*
!.env.example
docs
tests
uploads
micrach
*.out
*.test
  • Step 3: Build and inspect the image

Run:

docker build --tag micrach:wave1 .
docker image inspect micrach:wave1 --format '{{.Config.User}} {{json .Config.Entrypoint}}'

Expected inspect output:

micrach ["./micrach"]
  • Step 4: Confirm upload storage is writable by the runtime user

Run:

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
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:

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:

.github/workflows/codesee-arch-diagram.yml
  • Step 3: Add Dependabot

Create .github/dependabot.yml:

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:

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
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:

- 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:

cp .env.example .env
docker compose up -d db
go run .

Document automatic reload separately:

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:

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:

- `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:

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
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:

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:

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:

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:

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:

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.