From a25120cd1d477066726b0857907dfc82c70c3176 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Tue, 5 May 2026 08:19:04 +0200 Subject: [PATCH] feat: initial template (Go + Templ + HTMX + CDN Tailwind) --- .context/PROJECT.md | 13 ++++ .gitea/workflows/cd.yml | 116 +++++++++++++++++++++++++++++++++++ .gitignore | 3 + Dockerfile | 14 +++++ README.md | 14 ++++- Taskfile.yml | 26 ++++++++ cmd/__PROJECT_NAME__/main.go | 27 ++++++++ go.mod | 7 +++ internal/web/handler.go | 17 +++++ internal/web/index.templ | 16 +++++ internal/web/layout.templ | 19 ++++++ 11 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 .context/PROJECT.md create mode 100644 .gitea/workflows/cd.yml create mode 100644 Dockerfile create mode 100644 Taskfile.yml create mode 100644 cmd/__PROJECT_NAME__/main.go create mode 100644 go.mod create mode 100644 internal/web/handler.go create mode 100644 internal/web/index.templ create mode 100644 internal/web/layout.templ diff --git a/.context/PROJECT.md b/.context/PROJECT.md new file mode 100644 index 0000000..d687f20 --- /dev/null +++ b/.context/PROJECT.md @@ -0,0 +1,13 @@ +# __PROJECT_NAME__ + +## Identity + +- **Name**: __PROJECT_NAME__ +- **Owner**: Mathias +- **Client**: personal +- **Repo**: gitea.d-ma.be/mathias/__PROJECT_NAME__ +- **Status**: active + +## Stack + +Go + Templ + HTMX + CDN Tailwind. See `~/dev/.context/AGENT.md` for cross-project conventions. diff --git a/.gitea/workflows/cd.yml b/.gitea/workflows/cd.yml new file mode 100644 index 0000000..4b03645 --- /dev/null +++ b/.gitea/workflows/cd.yml @@ -0,0 +1,116 @@ +name: CD + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + +env: + IMAGE: __PROJECT_NAME__ + +jobs: + check: + name: Lint / Test / Vet + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: false + + - name: Install toolchain + run: | + go version + go install github.com/a-h/templ/cmd/templ@latest + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh \ + | sh -s -- -b "$(go env GOPATH)/bin" v2.11.4 + + - name: Run checks + run: task check + + build: + name: Build & Import + needs: check + runs-on: self-hosted + if: github.event_name != 'pull_request' + outputs: + image-tag: ${{ steps.meta.outputs.sha-tag }} + steps: + - uses: actions/checkout@v4 + + - name: Derive image tags + id: meta + run: | + SHA=$(git rev-parse --short HEAD) + echo "sha-tag=${SHA}" >> "$GITHUB_OUTPUT" + + - name: Build and push to local registry + run: | + REGISTRY="localhost:5000" + REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}" + buildah build \ + --label "org.opencontainers.image.revision=${{ github.sha }}" \ + -t ${REF} \ + -t ${REGISTRY}/${{ env.IMAGE }}:latest \ + . + buildah push --tls-verify=false ${REF} + buildah push --tls-verify=false ${REGISTRY}/${{ env.IMAGE }}:latest + echo "✓ Image pushed to ${REF}" + + deploy: + name: Deploy via GitOps + needs: build + runs-on: self-hosted + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + environment: staging + steps: + - name: Update image tag in infra repo + env: + IMAGE_TAG: ${{ needs.build.outputs.image-tag }} + DEPLOY_KEY: ${{ secrets.INFRA_DEPLOY_KEY }} + run: | + set -euo pipefail + mkdir -p ~/.ssh + echo "$DEPLOY_KEY" > ~/.ssh/id_infra + chmod 600 ~/.ssh/id_infra + ssh-keyscan -p 30022 10.0.1.20 >> ~/.ssh/known_hosts 2>/dev/null + export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_infra -o IdentitiesOnly=yes" + rm -rf /tmp/infra + git clone -b main ssh://git@10.0.1.20:30022/mathias/infra.git /tmp/infra + cd /tmp/infra + DEPLOYMENT="k3s/apps/__PROJECT_NAME__/deployment.yaml" + sed -i "s|image: localhost:5000/__PROJECT_NAME__:.*|image: localhost:5000/__PROJECT_NAME__:${IMAGE_TAG}|" "$DEPLOYMENT" + grep -q "localhost:5000/__PROJECT_NAME__:${IMAGE_TAG}" "$DEPLOYMENT" \ + || { echo "✗ image tag patch failed"; exit 1; } + if git diff --quiet "$DEPLOYMENT"; then + echo "ℹ image tag unchanged — skipping push" + else + git -c user.name="__PROJECT_NAME__ CI" \ + -c user.email="ci@__PROJECT_NAME__.local" \ + commit -m "chore(deploy): __PROJECT_NAME__ → ${IMAGE_TAG}" "$DEPLOYMENT" + git push origin main + echo "✓ pushed to infra repo" + fi + shred -u ~/.ssh/id_infra + + - name: Trigger Flux reconcile + run: | + kubectl -n flux-system annotate gitrepository flux-system \ + reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite + kubectl -n flux-system annotate kustomization apps \ + reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite + + - name: Verify rollout + run: | + kubectl rollout status deployment/__PROJECT_NAME__ \ + --namespace __PROJECT_NAME__ \ + --timeout=120s \ + || { + kubectl get pods -n __PROJECT_NAME__ -o wide + kubectl get events -n __PROJECT_NAME__ --sort-by='.lastTimestamp' | tail -20 + exit 1 + } diff --git a/.gitignore b/.gitignore index 5b90e79..902b7e7 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ go.work.sum # env file .env +# Project-specific +bin/ +*.templ.go diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b32c16e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.26-alpine AS build +WORKDIR /src +RUN apk add --no-cache git +RUN go install github.com/a-h/templ/cmd/templ@latest +COPY go.mod ./ +RUN go mod download +COPY . . +RUN templ generate && CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/app ./cmd/__PROJECT_NAME__ + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/app /app +USER nonroot:nonroot +EXPOSE 8080 +ENTRYPOINT ["/app"] diff --git a/README.md b/README.md index dede942..2e2c67c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,13 @@ -# template-go-web +# __PROJECT_NAME__ -Go + Templ + HTMX + Tailwind project template \ No newline at end of file +> Generated from `mathias/template-go-web`. + +## Bootstrap + +After creating from template, run: + +```bash +go mod tidy # regenerate go.sum with real module path +task generate # generate templ files +task build # build the binary +``` diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..9e94cdd --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,26 @@ +version: '3' + +tasks: + generate: + desc: Run templ generate + cmds: [templ generate] + build: + desc: Build the binary + deps: [generate] + cmds: [go build -o bin/__PROJECT_NAME__ ./cmd/__PROJECT_NAME__] + run: + deps: [build] + cmds: [./bin/__PROJECT_NAME__] + test: + desc: Run all tests + deps: [generate] + cmds: [go test ./... -race] + lint: + cmds: [golangci-lint run ./...] + check: + desc: Lint, vet, and test (used by CI) + deps: [generate] + cmds: + - golangci-lint run ./... + - go vet ./... + - go test ./... -race -count=1 diff --git a/cmd/__PROJECT_NAME__/main.go b/cmd/__PROJECT_NAME__/main.go new file mode 100644 index 0000000..e8959bf --- /dev/null +++ b/cmd/__PROJECT_NAME__/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "log/slog" + "net/http" + "os" + + "__MODULE_PATH__/internal/web" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + mux.Handle("/", web.NewHandler()) + + addr := ":8080" + logger.Info("__PROJECT_NAME__ starting", "addr", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + logger.Error("server stopped", "err", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..198b4ef --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module __MODULE_PATH__ + +go 1.26 + +require ( + github.com/a-h/templ v0.2.778 +) diff --git a/internal/web/handler.go b/internal/web/handler.go new file mode 100644 index 0000000..92cf8a1 --- /dev/null +++ b/internal/web/handler.go @@ -0,0 +1,17 @@ +package web + +import ( + "context" + "net/http" +) + +func NewHandler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + _ = Index().Render(context.Background(), w) + }) + mux.HandleFunc("/api/hello", func(w http.ResponseWriter, r *http.Request) { + _ = Hello("world").Render(context.Background(), w) + }) + return mux +} diff --git a/internal/web/index.templ b/internal/web/index.templ new file mode 100644 index 0000000..7a86488 --- /dev/null +++ b/internal/web/index.templ @@ -0,0 +1,16 @@ +package web + +templ Index() { + @Layout("__PROJECT_NAME__") { +

__PROJECT_NAME__

+ +
+ } +} + +templ Hello(name string) { +

Hello, { name }!

+} diff --git a/internal/web/layout.templ b/internal/web/layout.templ new file mode 100644 index 0000000..dfedd10 --- /dev/null +++ b/internal/web/layout.templ @@ -0,0 +1,19 @@ +package web + +templ Layout(title string) { + + + + + + { title } + + + + +
+ { children... } +
+ + +}