* chore: add agents md * docs: expand repository and setup guides * docs: plan project modernization * docs: add Wave 1 implementation plan * test: characterize legacy behavior * build: update Go and Fiber v2 * fix(gateway): preserve duplicate auth behavior * refactor(db): migrate to pgx v5 * refactor: replace deprecated I/O APIs * build: add local PostgreSQL compose * build: add multi-stage image * ci: replace legacy deployment workflow * fix(deps): update vulnerable Go modules * docs: document modern development workflow
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 .ormake 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 deprecatedioutil; 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, andcontrollers.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 usescontext.Background(). -
Produces: repository and DB operations accepting
context.Context; pgx v5 transaction types;toInt32s([]int) []int32for PostgreSQLinteger[]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
osandio, 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_URLconfiguration. -
Produces: PostgreSQL at
localhost:5432, database/user/passwordmicrach, persistent volumemicrach-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:wave1Linux 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.