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 142 additions and 115 deletions
+6 -4
View File
@@ -20,10 +20,12 @@
# 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, or in # under .git/modules/, nested again for a submodule's own submodules, or in
# its own .git directory when it keeps one. # its own .git directory when it keeps one.
# KNOWN GAP: `**/.git/modules/**/config` also matches the git directory # KNOWN GAP: a submodule whose name has a `config` segment (`config`,
# of a submodule named `config` or `deploy/config`, so all of it stays # `deploy/config`, `config/lib`) loses its whole git directory, because
# out and Go's version stamping fails the build; nothing leaks. Name # `**/.git/modules/**/config` also matches that segment's directory
# such a submodule without that segment: `git submodule add --name`. # 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/config
**/.git/modules/**/config **/.git/modules/**/config
+21 -6
View File
@@ -24,12 +24,27 @@ fmt-check, and commit.
- 2026-10-04: The canonical `.dockerignore` now also keeps out the git `config` - 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 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 image (issue 88): both git patterns now carry the `**/` prefix. A submodule
named `config` or `deploy/config` still loses its whole git directory, so Go's whose name has a `config` segment (`config`, `deploy/config`, `config/lib`)
version stamping fails the build; the file records this as a `KNOWN GAP:` with still loses its whole git directory, so Go's version stamping fails the build;
the remedy, `git submodule add --name`. Closing it would take a wildcard the file records this as a `KNOWN GAP:` with the remedy,
re-include, which makes BuildKit walk every excluded directory, such as `git submodule add --name`. Closing it would take a wildcard re-include, which
`node_modules`, on every build. `REPO_POLICIES.md` and both checklists say so makes BuildKit walk every excluded directory, such as `node_modules`, on every
in the same words. 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.
+16 -15
View File
@@ -68,21 +68,22 @@ with your task.
each submodule's under `.git/modules/`, and that of a submodule keeping 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 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 hold a credential: a password in a remote URL, or the token the CI
checkout step stores there. A submodule named `config` or `deploy/config` checkout step stores there. A submodule whose name has a `config` segment
loses its whole git directory to `**/.git/modules/**/config`, and Go's (`config`, `deploy/config`, `config/lib`) loses its whole git directory to
version stamping then fails the build: give it a name without that segment `**/.git/modules/**/config`, and Go's version stamping then fails the
(`git submodule add --name`). The stage that compiles has `git` (the build: give it a name without that segment (`git submodule add --name`).
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and The stage that compiles has `git` (the Debian Go image has it; an alpine
takes the version from the `VERSION` build argument when one is given, one needs `apk add --no-cache git`) and takes the version from the
otherwise from `git describe --tags --always`. That gives the tag on a `VERSION` build argument when one is given, otherwise from
tagged commit; on a later commit, the tag, the number of commits since it `git describe --tags --always`. That gives the tag on a tagged commit; on
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no a later commit, the tag, the number of commits since it and the short
tag is reachable. The stage that compiles also marks its working directory commit (`v1.2.3-4-gabc1234`); and the short commit when no tag is
safe for git (`git config --system --add safe.directory /src`): a context reachable. The stage that compiles also marks its working directory safe
sent as a tar stream keeps the sender's file owners, and git refuses a for git (`git config --system --add safe.directory /src`): a context sent
checkout owned by another user, so the version would come out empty. as a tar stream keeps the sender's file owners, and git refuses a checkout
`ARG VERSION` has no default, and the build fails if the context carries owned by another user, so the version would come out empty. `ARG VERSION`
`.git` and the version still comes out empty, `dev` or `unknown`. A plain has no default, and the build fails if the context carries `.git` and the
version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that `docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument. refuses an empty build argument drops that refusal and keeps the argument.
`script/docker` and `script/cibuild` pass the version they compute on the `script/docker` and `script/cibuild` pass the version they compute on the
+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 {
+16 -15
View File
@@ -76,21 +76,22 @@ Template files can be fetched from:
each submodule's under `.git/modules/`, and that of a submodule keeping 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 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 hold a credential: a password in a remote URL, or the token the CI
checkout step stores there. A submodule named `config` or `deploy/config` checkout step stores there. A submodule whose name has a `config` segment
loses its whole git directory to `**/.git/modules/**/config`, and Go's (`config`, `deploy/config`, `config/lib`) loses its whole git directory to
version stamping then fails the build: give it a name without that segment `**/.git/modules/**/config`, and Go's version stamping then fails the
(`git submodule add --name`). The stage that compiles has `git` (the build: give it a name without that segment (`git submodule add --name`).
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and The stage that compiles has `git` (the Debian Go image has it; an alpine
takes the version from the `VERSION` build argument when one is given, one needs `apk add --no-cache git`) and takes the version from the
otherwise from `git describe --tags --always`. That gives the tag on a `VERSION` build argument when one is given, otherwise from
tagged commit; on a later commit, the tag, the number of commits since it `git describe --tags --always`. That gives the tag on a tagged commit; on
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no a later commit, the tag, the number of commits since it and the short
tag is reachable. The stage that compiles also marks its working directory commit (`v1.2.3-4-gabc1234`); and the short commit when no tag is
safe for git (`git config --system --add safe.directory /src`): a context reachable. The stage that compiles also marks its working directory safe
sent as a tar stream keeps the sender's file owners, and git refuses a for git (`git config --system --add safe.directory /src`): a context sent
checkout owned by another user, so the version would come out empty. as a tar stream keeps the sender's file owners, and git refuses a checkout
`ARG VERSION` has no default, and the build fails if the context carries owned by another user, so the version would come out empty. `ARG VERSION`
`.git` and the version still comes out empty, `dev` or `unknown`. A plain has no default, and the build fails if the context carries `.git` and the
version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that `docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument. refuses an empty build argument drops that refusal and keeps the argument.
- The Dockerfile carries a `lint` phase and a `test` phase, each invoking - The Dockerfile carries a `lint` phase and a `test` phase, each invoking
+28 -18
View File
@@ -236,29 +236,39 @@ 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
has no `apk`, so install with `apt-get` under the Debian package name
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
lists in the same `RUN`, so the layer does not keep them:
```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 - `.dockerignore` lets `.git` into the build context. It keeps out every git
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the `config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
repository's own, each submodule's under `.git/modules/`, and that of a repository's own, each submodule's under `.git/modules/`, and that of a
submodule keeping its own `.git` directory. `git describe` does not need 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 them, and each can hold a credential: a password in a remote URL, or the
token the CI checkout step stores there. A submodule named `config` or token the CI checkout step stores there. A submodule whose name has a
`deploy/config` loses its whole git directory to `config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
`**/.git/modules/**/config`, and Go's version stamping then fails the git directory to `**/.git/modules/**/config`, and Go's version stamping
build: give it a name without that segment (`git submodule add --name`). then fails the build: give it a name without that segment
The stage that compiles has `git` (the Debian Go image has it; an alpine (`git submodule add --name`). The stage that compiles has `git` (the
one needs `apk add --no-cache git`) and takes the version from the Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
`VERSION` build argument when one is given, otherwise from takes the version from the `VERSION` build argument when one is given,
`git describe --tags --always`. That gives the tag on a tagged commit; on otherwise from `git describe --tags --always`. That gives the tag on a
a later commit, the tag, the number of commits since it and the short tagged commit; on a later commit, the tag, the number of commits since it
commit (`v1.2.3-4-gabc1234`); and the short commit when no tag is and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
reachable. The stage that compiles also marks its working directory safe tag is reachable. The stage that compiles also marks its working directory
for git (`git config --system --add safe.directory /src`): a context sent safe for git (`git config --system --add safe.directory /src`): a context
as a tar stream keeps the sender's file owners, and git refuses a checkout sent as a tar stream keeps the sender's file owners, and git refuses a
owned by another user, so the version would come out empty. `ARG VERSION` checkout owned by another user, so the version would come out empty.
has no default, and the build fails if the context carries `.git` and the `ARG VERSION` has no default, and the build fails if the context carries
version still comes out empty, `dev` or `unknown`. A plain `.git` and the version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that `docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument. refuses an empty build argument drops that refusal and keeps the argument.