3 Commits
Author SHA1 Message Date
sneak 44ef6d4158 Keep a submodule's own .git/config out of the build context (closes #88)
check / check (push) Failing after 2s
The canonical .dockerignore kept out .git/config and the configs under
.git/modules/, but not the config of a submodule that keeps its own .git
directory, so its credential reached the image. Both git patterns now
carry the **/ prefix.

A submodule whose name has a config segment (config, deploy/config,
config/lib) still loses its whole git directory, and Go's version
stamping fails the build. Closing that needs a wildcard re-include,
which makes BuildKit walk every excluded directory on every build, so
the file records it as a KNOWN GAP with the remedy, git submodule add
--name. REPO_POLICIES.md and both checklists say the same.

Model: opus-5-5
2026-10-04 10:04:42 +00:00
clawbot f3ad01a78c Install lint-phase libraries with apt-get, not apk (closes #83)
check / check (push) Failing after 1s
A key point under the canonical Go `Dockerfile` example in `prompts/REPO_POLICIES.md` said to install lint-phase system libraries with `apk add`. The lint phase is built on the golangci-lint image, which is Debian-based and has no `apk`, and `vips-dev` is the alpine package name. The point now gives the `apt-get` command with the Debian package name (`libvips-dev`) and removes the package lists in the same `RUN`.

The other `apk` mentions are about the alpine build stage or `script/bootstrap` on the host and stay. Nothing is pinned or unpinned; that is the open owner question on #72.

Model: opus-5-5
2026-10-04 11:31:48 +02:00
clawbot c32b10e77f Let fx own signals and the exit code in the server example (closes #86)
check / check (push) Failing after 2s
The server lifecycle example in `prompts/GO_HTTP_SERVER_CONVENTIONS.md` dropped its exit code, installed its own SIGINT/SIGTERM handler beside the one fx's `Run()` installs, and exited from a goroutine when Sentry could not start, so no stop hook ran.

fx now owns signals and the exit code: `main` calls `Run()`; a listen error shuts fx down with `fx.ExitCode(1)` through `fx.Shutdowner`; `enableSentry()` returns its error from the start hook; the stop hook shuts the HTTP server down within 5 seconds, flushes Sentry, and fails when requests are still running. The start hook builds the HTTP server before the listen goroutine so the stop hook can reach it. A new paragraph says who owns signals and the exit code.

Model: opus-5-5
2026-10-04 11:02:18 +02:00
6 changed files with 129 additions and 75 deletions
+10 -3
View File
@@ -18,9 +18,16 @@
# does not need .git/config; that file can hold a credential, such as a # does not need .git/config; that file can hold a credential, such as a
# password in a remote URL or the token the CI checkout step stores there. # password in a remote URL or the token the CI checkout step stores there.
# Each submodule keeps a config with the same exposure in its git directory # Each submodule keeps a config with the same exposure in its git directory
# under .git/modules/, nested again for a submodule's own submodules. # under .git/modules/, nested again for a submodule's own submodules, or in
.git/config # its own .git directory when it keeps one.
.git/modules/**/config # KNOWN GAP: a submodule whose name has a `config` segment (`config`,
# `deploy/config`, `config/lib`) loses its whole git directory, because
# `**/.git/modules/**/config` also matches that segment's directory
# under .git/modules/. Go's version stamping then fails the build;
# nothing leaks. Name such a submodule without that segment:
# `git submodule add --name`.
**/.git/config
**/.git/modules/**/config
# Agent scratch: one full checkout of the repo per in-flight agent. # Agent scratch: one full checkout of the repo per in-flight agent.
# Anchored because it occurs once where agents run at the repo root. # Anchored because it occurs once where agents run at the repo root.
+24
View File
@@ -21,6 +21,30 @@ fmt-check, and commit.
# Completed Steps # Completed Steps
- 2026-10-04: The canonical `.dockerignore` now also keeps out the git `config`
of a submodule that keeps its own `.git` directory, which still reached the
image (issue 88): both git patterns now carry the `**/` prefix. A submodule
whose name has a `config` segment (`config`, `deploy/config`, `config/lib`)
still loses its whole git directory, so Go's version stamping fails the build;
the file records this as a `KNOWN GAP:` with the remedy,
`git submodule add --name`. Closing it would take a wildcard re-include, which
makes BuildKit walk every excluded directory, such as `node_modules`, on every
build. `REPO_POLICIES.md` and both checklists say so in the same words.
- 2026-10-04: The note under the canonical Go `Dockerfile` example in
`REPO_POLICIES.md` now installs lint-phase system libraries with `apt-get`
under their Debian package names (issue 83). The `golangci/golangci-lint`
image is Debian-based and has no `apk`, so the old `apk add` instruction
failed as written. Nothing is pinned or unpinned; that is still open on
issue 72.
- 2026-10-04: Fixed the server lifecycle example in
`prompts/GO_HTTP_SERVER_CONVENTIONS.md` (issue 86). Only fx handles SIGINT and
SIGTERM, and `Run()` in `main` exits with the shutdown's exit code. A listen
error asks fx to shut down with exit code 1 through `fx.Shutdowner`; a Sentry
start failure is returned from the server's start hook instead of calling
`os.Exit` from a goroutine, so the stop hooks of what had started still run.
The server's stop hook shuts the HTTP server down within 5 seconds and fails
when requests are still running. A new paragraph says who owns signals and the
exit code.
- 2026-10-04: `REPO_POLICIES.md` now says how a Go tool a repo needs on the host - 2026-10-04: `REPO_POLICIES.md` now says how a Go tool a repo needs on the host
is pinned (issue 37): installed with `go install` pinned to a commit hash, is pinned (issue 37): installed with `go install` pinned to a commit hash,
never tracked as a `go.mod` tool dependency or through a `tools.go` file. never tracked as a `go.mod` tool dependency or through a `tools.go` file.
+9 -4
View File
@@ -63,10 +63,15 @@ with your task.
here run anywhere other than the repo root, the anchored entry misses here run anywhere other than the repo root, the anchored entry misses
`services/api/.claude/`: add anchored entries for those directories. `services/api/.claude/`: add anchored entries for those directories.
- [ ] If the repo embeds a version in a binary: `.dockerignore` lets `.git` into - [ ] If the repo embeds a version in a binary: `.dockerignore` lets `.git` into
the build context. It keeps out `.git/config` and each submodule's the build context. It keeps out every git `config` at any depth
`config` under `.git/modules/` at any depth (`.git/modules/**/config`), (`**/.git/config`, `**/.git/modules/**/config`): the repository's own,
which `git describe` does not need and which can hold a credential: a each submodule's under `.git/modules/`, and that of a submodule keeping
password in a remote URL, or the token the CI checkout step stores there. its own `.git` directory. `git describe` does not need them, and each can
hold a credential: a password in a remote URL, or the token the CI
checkout step stores there. A submodule whose name has a `config` segment
(`config`, `deploy/config`, `config/lib`) loses its whole git directory to
`**/.git/modules/**/config`, and Go's version stamping then fails the
build: give it a name without that segment (`git submodule add --name`).
The stage that compiles has `git` (the Debian Go image has it; an alpine The stage that compiles has `git` (the Debian Go image has it; an alpine
one needs `apk add --no-cache git`) and takes the version from the one needs `apk add --no-cache git`) and takes the version from the
`VERSION` build argument when one is given, otherwise from `VERSION` build argument when one is given, otherwise from
+55 -57
View File
@@ -106,6 +106,9 @@ project-root/
package main package main
import ( import (
"os/signal"
"syscall"
"yourproject/internal/config" "yourproject/internal/config"
"yourproject/internal/database" "yourproject/internal/database"
"yourproject/internal/globals" "yourproject/internal/globals"
@@ -126,6 +129,9 @@ func main() {
globals.Appname = Appname globals.Appname = Appname
globals.Version = Version globals.Version = Version
// A write to a closed stdout or stderr must not end the process.
signal.Ignore(syscall.SIGPIPE)
fx.New( fx.New(
fx.Provide( fx.Provide(
config.New, config.New,
@@ -198,7 +204,8 @@ Providers are resolved automatically by fx, but conceptually follow this order:
Database) Database)
6. `middleware.New` - Middleware (depends on Logger, Globals, Config) 6. `middleware.New` - Middleware (depends on Logger, Globals, Config)
7. `handlers.New` - Handlers (depends on Logger, Globals, Database, Healthcheck) 7. `handlers.New` - Handlers (depends on Logger, Globals, Database, Healthcheck)
8. `server.New` - Server (depends on all above) 8. `server.New` - Server (depends on all above, and on `fx.Shutdowner`, which fx
provides itself)
--- ---
@@ -217,16 +224,14 @@ type ServerParams struct {
Config *config.Config Config *config.Config
Middleware *middleware.Middleware Middleware *middleware.Middleware
Handlers *handlers.Handlers Handlers *handlers.Handlers
Shutdowner fx.Shutdowner
} }
type Server struct { type Server struct {
startupTime time.Time startupTime time.Time
port int port int
exitCode int
sentryEnabled bool sentryEnabled bool
log *slog.Logger log *slog.Logger
ctx context.Context
cancelFunc context.CancelFunc
httpServer *http.Server httpServer *http.Server
router *chi.Mux router *chi.Mux
params ServerParams params ServerParams
@@ -248,13 +253,15 @@ func New(lc fx.Lifecycle, params ServerParams) (*Server, error) {
lc.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error { OnStart: func(ctx context.Context) error {
s.startupTime = time.Now() s.startupTime = time.Now()
go s.Run() if err := s.enableSentry(); err != nil {
return nil return err
}, }
OnStop: func(ctx context.Context) error { s.SetupRoutes()
// Server shutdown logic s.httpServer = s.newHTTPServer()
go s.serveUntilShutdown()
return nil return nil
}, },
OnStop: s.cleanShutdown,
}) })
return s, nil return s, nil
} }
@@ -264,23 +271,25 @@ func New(lc fx.Lifecycle, params ServerParams) (*Server, error) {
```go ```go
// internal/server/http.go // internal/server/http.go
func (s *Server) serveUntilShutdown() { func (s *Server) newHTTPServer() *http.Server {
listenAddr := fmt.Sprintf(":%d", s.params.Config.Port) return &http.Server{
s.httpServer = &http.Server{ Addr: fmt.Sprintf(":%d", s.params.Config.Port),
Addr: listenAddr,
ReadTimeout: 10 * time.Second, ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20, MaxHeaderBytes: 1 << 20,
Handler: s, Handler: s,
} }
}
s.SetupRoutes() // serveUntilShutdown returns when the stop hook shuts the HTTP server down.
// If it stops for any other reason, such as its port being taken, it asks fx
s.log.Info("http begin listen", "listenaddr", listenAddr) // to shut down with exit code 1.
func (s *Server) serveUntilShutdown() {
s.log.Info("http begin listen", "listenaddr", s.httpServer.Addr)
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
s.log.Error("listen error", "error", err) s.log.Error("listen error", "error", err)
if s.cancelFunc != nil { if err := s.params.Shutdowner.Shutdown(fx.ExitCode(1)); err != nil {
s.cancelFunc() s.log.Error("shutdown request failed", "error", err)
} }
} }
} }
@@ -292,43 +301,30 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
## Signal Handling and Graceful Shutdown ## Signal Handling and Graceful Shutdown
fx owns SIGINT, SIGTERM and the exit code. `Run()` in `main` waits for one of
those signals or for a call to `Shutdown()` on `fx.Shutdowner`, runs the stop
hooks, and exits 0 after a signal, or with the code the call gave in
`fx.ExitCode`. It exits 1 instead when a start hook or a stop hook returns an
error; when a start hook fails, fx first runs the stop hooks of everything
already started. No other code calls `signal.Notify` or `os.Exit`: the listen
error above asks fx to shut down with `fx.ExitCode(1)`, and a Sentry start
failure is returned from the start hook. Each component releases its own
resources in its own stop hook, which fx runs in the reverse order of start.
```go ```go
func (s *Server) serve() int { // cleanShutdown is the server's stop hook. It fails when requests are still
s.ctx, s.cancelFunc = context.WithCancel(context.Background()) // running after 5 seconds.
func (s *Server) cleanShutdown(ctx context.Context) error {
// Signal watcher ctxShutdown, shutdownCancel := context.WithTimeout(ctx, 5*time.Second)
go func() { defer shutdownCancel()
c := make(chan os.Signal, 1) err := s.httpServer.Shutdown(ctxShutdown)
signal.Ignore(syscall.SIGPIPE)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
sig := <-c
s.log.Info("signal received", "signal", sig)
if s.cancelFunc != nil {
s.cancelFunc()
}
}()
go s.serveUntilShutdown()
for range s.ctx.Done() {
}
s.cleanShutdown()
return s.exitCode
}
func (s *Server) cleanShutdown() {
s.exitCode = 0
ctxShutdown, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := s.httpServer.Shutdown(ctxShutdown); err != nil {
s.log.Error("server clean shutdown failed", "error", err)
}
if shutdownCancel != nil {
shutdownCancel()
}
s.cleanupForExit()
if s.sentryEnabled { if s.sentryEnabled {
sentry.Flush(2 * time.Second) sentry.Flush(2 * time.Second)
} }
if err != nil {
return fmt.Errorf("http server shutdown: %w", err)
}
return nil
} }
``` ```
@@ -1160,11 +1156,11 @@ s.router.Get("/.well-known/healthcheck", s.h.HandleHealthCheck())
Sentry is conditionally enabled based on `SENTRY_DSN` environment variable: Sentry is conditionally enabled based on `SENTRY_DSN` environment variable:
```go ```go
func (s *Server) enableSentry() { func (s *Server) enableSentry() error {
s.sentryEnabled = false s.sentryEnabled = false
if s.params.Config.SentryDSN == "" { if s.params.Config.SentryDSN == "" {
return return nil
} }
err := sentry.Init(sentry.ClientOptions{ err := sentry.Init(sentry.ClientOptions{
@@ -1172,15 +1168,17 @@ func (s *Server) enableSentry() {
Release: fmt.Sprintf("%s-%s", s.params.Globals.Appname, s.params.Globals.Version), Release: fmt.Sprintf("%s-%s", s.params.Globals.Appname, s.params.Globals.Version),
}) })
if err != nil { if err != nil {
s.log.Error("sentry init failure", "error", err) return fmt.Errorf("sentry init failure: %w", err)
os.Exit(1)
return
} }
s.log.Info("sentry error reporting activated") s.log.Info("sentry error reporting activated")
s.sentryEnabled = true s.sentryEnabled = true
return nil
} }
``` ```
The server's start hook calls `enableSentry()` and returns its error, so a DSN
Sentry rejects stops startup and fx exits 1.
Sentry middleware with repanic (bubbles panics to chi's Recoverer): Sentry middleware with repanic (bubbles panics to chi's Recoverer):
```go ```go
@@ -1192,7 +1190,7 @@ if s.sentryEnabled {
} }
``` ```
Flush Sentry on shutdown: Flush Sentry in the server's stop hook, `cleanShutdown()`:
```go ```go
if s.sentryEnabled { if s.sentryEnabled {
+9 -4
View File
@@ -71,10 +71,15 @@ Template files can be fetched from:
will run them in subdirectories, `services/api/.claude/` needs its own will run them in subdirectories, `services/api/.claude/` needs its own
anchored entry. anchored entry.
- If the image embeds a version in a binary: `.dockerignore` lets `.git` - If the image embeds a version in a binary: `.dockerignore` lets `.git`
into the build context. It keeps out `.git/config` and each submodule's into the build context. It keeps out every git `config` at any depth
`config` under `.git/modules/` at any depth (`.git/modules/**/config`), (`**/.git/config`, `**/.git/modules/**/config`): the repository's own,
which `git describe` does not need and which can hold a credential: a each submodule's under `.git/modules/`, and that of a submodule keeping
password in a remote URL, or the token the CI checkout step stores there. its own `.git` directory. `git describe` does not need them, and each can
hold a credential: a password in a remote URL, or the token the CI
checkout step stores there. A submodule whose name has a `config` segment
(`config`, `deploy/config`, `config/lib`) loses its whole git directory to
`**/.git/modules/**/config`, and Go's version stamping then fails the
build: give it a name without that segment (`git submodule add --name`).
The stage that compiles has `git` (the Debian Go image has it; an alpine The stage that compiles has `git` (the Debian Go image has it; an alpine
one needs `apk add --no-cache git`) and takes the version from the one needs `apk add --no-cache git`) and takes the version from the
`VERSION` build argument when one is given, otherwise from `VERSION` build argument when one is given, otherwise from
+22 -7
View File
@@ -236,13 +236,28 @@ style conventions are in separate documents:
(e.g. a web frontend compiled in a separate stage), the lint phase must (e.g. a web frontend compiled in a separate stage), the lint phase must
create placeholder files so the embed directives resolve. Example: create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
- If the project requires CGO or system libraries for linting (e.g. - If the project requires CGO or system libraries for linting, install them
`vips-dev`), install them in the lint phase with `apk add`. in the lint phase. The `golangci/golangci-lint` image is Debian-based and
- `.dockerignore` lets `.git` into the build context. It keeps out has no `apk`, so install with `apt-get` under the Debian package name
`.git/config` and each submodule's `config` under `.git/modules/` at any (`libvips-dev`, where alpine says `vips-dev`), and delete the package
depth (`.git/modules/**/config`), which `git describe` does not need and lists in the same `RUN`, so the layer does not keep them:
which can hold a credential: a password in a remote URL, or the token the
CI checkout step stores there. The stage that compiles has `git` (the ```dockerfile
RUN apt-get update \
&& apt-get install -y --no-install-recommends libvips-dev \
&& rm -rf /var/lib/apt/lists/*
```
- `.dockerignore` lets `.git` into the build context. It keeps out every git
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
repository's own, each submodule's under `.git/modules/`, and that of a
submodule keeping its own `.git` directory. `git describe` does not need
them, and each can hold a credential: a password in a remote URL, or the
token the CI checkout step stores there. A submodule whose name has a
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
git directory to `**/.git/modules/**/config`, and Go's version stamping
then fails the build: give it a name without that segment
(`git submodule add --name`). The stage that compiles has `git` (the
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
takes the version from the `VERSION` build argument when one is given, takes the version from the `VERSION` build argument when one is given,
otherwise from `git describe --tags --always`. That gives the tag on a otherwise from `git describe --tags --always`. That gives the tag on a