1 Commits
Author SHA1 Message Date
sneak 73bbe8f736 Keep a submodule's own .git/config out of the build context (closes #88)
check / check (push) Failing after 1s
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 named config or deploy/config 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 08:30:37 +00:00
6 changed files with 115 additions and 142 deletions
+4 -6
View File
@@ -20,12 +20,10 @@
# 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: a submodule whose name has a `config` segment (`config`, # KNOWN GAP: `**/.git/modules/**/config` also matches the git directory
# `deploy/config`, `config/lib`) loses its whole git directory, because # of a submodule named `config` or `deploy/config`, so all of it stays
# `**/.git/modules/**/config` also matches that segment's directory # out and Go's version stamping fails the build; nothing leaks. Name
# under .git/modules/. Go's version stamping then fails the build; # such a submodule without that segment: `git submodule add --name`.
# nothing leaks. Name such a submodule without that segment:
# `git submodule add --name`.
**/.git/config **/.git/config
**/.git/modules/**/config **/.git/modules/**/config
+6 -21
View File
@@ -24,27 +24,12 @@ 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
whose name has a `config` segment (`config`, `deploy/config`, `config/lib`) named `config` or `deploy/config` still loses its whole git directory, so Go's
still loses its whole git directory, so Go's version stamping fails the build; version stamping fails the build; the file records this as a `KNOWN GAP:` with
the file records this as a `KNOWN GAP:` with the remedy, the remedy, `git submodule add --name`. Closing it would take a wildcard
`git submodule add --name`. Closing it would take a wildcard re-include, which re-include, which makes BuildKit walk every excluded directory, such as
makes BuildKit walk every excluded directory, such as `node_modules`, on every `node_modules`, on every build. `REPO_POLICIES.md` and both checklists say so
build. `REPO_POLICIES.md` and both checklists say so in the same words. 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.
+15 -16
View File
@@ -68,22 +68,21 @@ 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 whose name has a `config` segment checkout step stores there. A submodule named `config` or `deploy/config`
(`config`, `deploy/config`, `config/lib`) loses its whole git directory to loses its whole git directory to `**/.git/modules/**/config`, and Go's
`**/.git/modules/**/config`, and Go's version stamping then fails the version stamping then fails the build: give it a name without that segment
build: give it a name without that segment (`git submodule add --name`). (`git submodule add --name`). The stage that compiles has `git` (the
The stage that compiles has `git` (the Debian Go image has it; an alpine Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
one needs `apk add --no-cache git`) and takes the version from the takes the version from the `VERSION` build argument when one is given,
`VERSION` build argument when one is given, otherwise from otherwise from `git describe --tags --always`. That gives the tag on a
`git describe --tags --always`. That gives the tag on a tagged commit; on tagged commit; on a later commit, the tag, the number of commits since it
a later commit, the tag, the number of commits since it and the short and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
commit (`v1.2.3-4-gabc1234`); and the short commit when no tag is tag is reachable. The stage that compiles also marks its working directory
reachable. The stage that compiles also marks its working directory safe safe for git (`git config --system --add safe.directory /src`): a context
for git (`git config --system --add safe.directory /src`): a context sent sent as a tar stream keeps the sender's file owners, and git refuses a
as a tar stream keeps the sender's file owners, and git refuses a checkout checkout owned by another user, so the version would come out empty.
owned by another user, so the version would come out empty. `ARG VERSION` `ARG VERSION` has no default, and the build fails if the context carries
has no default, and the build fails if the context carries `.git` and the `.git` and the version still comes out empty, `dev` or `unknown`. A plain
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
+57 -55
View File
@@ -106,9 +106,6 @@ 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"
@@ -129,9 +126,6 @@ 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,
@@ -204,8 +198,7 @@ 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, and on `fx.Shutdowner`, which fx 8. `server.New` - Server (depends on all above)
provides itself)
--- ---
@@ -224,14 +217,16 @@ 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
@@ -253,15 +248,13 @@ 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()
if err := s.enableSentry(); err != nil { go s.Run()
return err return nil
} },
s.SetupRoutes() OnStop: func(ctx context.Context) error {
s.httpServer = s.newHTTPServer() // Server shutdown logic
go s.serveUntilShutdown()
return nil return nil
}, },
OnStop: s.cleanShutdown,
}) })
return s, nil return s, nil
} }
@@ -271,25 +264,23 @@ func New(lc fx.Lifecycle, params ServerParams) (*Server, error) {
```go ```go
// internal/server/http.go // internal/server/http.go
func (s *Server) newHTTPServer() *http.Server { func (s *Server) serveUntilShutdown() {
return &http.Server{ listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
Addr: fmt.Sprintf(":%d", s.params.Config.Port), s.httpServer = &http.Server{
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,
} }
}
// serveUntilShutdown returns when the stop hook shuts the HTTP server down. s.SetupRoutes()
// If it stops for any other reason, such as its port being taken, it asks fx
// to shut down with exit code 1. s.log.Info("http begin listen", "listenaddr", listenAddr)
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 err := s.params.Shutdowner.Shutdown(fx.ExitCode(1)); err != nil { if s.cancelFunc != nil {
s.log.Error("shutdown request failed", "error", err) s.cancelFunc()
} }
} }
} }
@@ -301,30 +292,43 @@ 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
// cleanShutdown is the server's stop hook. It fails when requests are still func (s *Server) serve() int {
// running after 5 seconds. s.ctx, s.cancelFunc = context.WithCancel(context.Background())
func (s *Server) cleanShutdown(ctx context.Context) error {
ctxShutdown, shutdownCancel := context.WithTimeout(ctx, 5*time.Second) // Signal watcher
defer shutdownCancel() go func() {
err := s.httpServer.Shutdown(ctxShutdown) c := make(chan os.Signal, 1)
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
} }
``` ```
@@ -1156,11 +1160,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() error { func (s *Server) enableSentry() {
s.sentryEnabled = false s.sentryEnabled = false
if s.params.Config.SentryDSN == "" { if s.params.Config.SentryDSN == "" {
return nil return
} }
err := sentry.Init(sentry.ClientOptions{ err := sentry.Init(sentry.ClientOptions{
@@ -1168,17 +1172,15 @@ func (s *Server) enableSentry() error {
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 {
return fmt.Errorf("sentry init failure: %w", err) s.log.Error("sentry init failure", "error", 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
@@ -1190,7 +1192,7 @@ if s.sentryEnabled {
} }
``` ```
Flush Sentry in the server's stop hook, `cleanShutdown()`: Flush Sentry on shutdown:
```go ```go
if s.sentryEnabled { if s.sentryEnabled {
+15 -16
View File
@@ -76,22 +76,21 @@ 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 whose name has a `config` segment checkout step stores there. A submodule named `config` or `deploy/config`
(`config`, `deploy/config`, `config/lib`) loses its whole git directory to loses its whole git directory to `**/.git/modules/**/config`, and Go's
`**/.git/modules/**/config`, and Go's version stamping then fails the version stamping then fails the build: give it a name without that segment
build: give it a name without that segment (`git submodule add --name`). (`git submodule add --name`). The stage that compiles has `git` (the
The stage that compiles has `git` (the Debian Go image has it; an alpine Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
one needs `apk add --no-cache git`) and takes the version from the takes the version from the `VERSION` build argument when one is given,
`VERSION` build argument when one is given, otherwise from otherwise from `git describe --tags --always`. That gives the tag on a
`git describe --tags --always`. That gives the tag on a tagged commit; on tagged commit; on a later commit, the tag, the number of commits since it
a later commit, the tag, the number of commits since it and the short and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
commit (`v1.2.3-4-gabc1234`); and the short commit when no tag is tag is reachable. The stage that compiles also marks its working directory
reachable. The stage that compiles also marks its working directory safe safe for git (`git config --system --add safe.directory /src`): a context
for git (`git config --system --add safe.directory /src`): a context sent sent as a tar stream keeps the sender's file owners, and git refuses a
as a tar stream keeps the sender's file owners, and git refuses a checkout checkout owned by another user, so the version would come out empty.
owned by another user, so the version would come out empty. `ARG VERSION` `ARG VERSION` has no default, and the build fails if the context carries
has no default, and the build fails if the context carries `.git` and the `.git` and the version still comes out empty, `dev` or `unknown`. A plain
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
+18 -28
View File
@@ -236,39 +236,29 @@ 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, install them - If the project requires CGO or system libraries for linting (e.g.
in the lint phase. The `golangci/golangci-lint` image is Debian-based and `vips-dev`), install them in the lint phase with `apk add`.
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 whose name has a token the CI checkout step stores there. A submodule named `config` or
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole `deploy/config` loses its whole git directory to
git directory to `**/.git/modules/**/config`, and Go's version stamping `**/.git/modules/**/config`, and Go's version stamping then fails the
then fails the build: give it a name without that segment build: give it a name without that segment (`git submodule add --name`).
(`git submodule add --name`). The stage that compiles has `git` (the The stage that compiles has `git` (the Debian Go image has it; an alpine
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and one needs `apk add --no-cache git`) and takes the version from the
takes the version from the `VERSION` build argument when one is given, `VERSION` build argument when one is given, otherwise from
otherwise from `git describe --tags --always`. That gives the tag on a `git describe --tags --always`. That gives the tag on a tagged commit; on
tagged commit; on a later commit, the tag, the number of commits since it a later commit, the tag, the number of commits since it and the short
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no commit (`v1.2.3-4-gabc1234`); and the short commit when no tag is
tag is reachable. The stage that compiles also marks its working directory reachable. The stage that compiles also marks its working directory safe
safe for git (`git config --system --add safe.directory /src`): a context for git (`git config --system --add safe.directory /src`): a context sent
sent as a tar stream keeps the sender's file owners, and git refuses a as a tar stream keeps the sender's file owners, and git refuses a checkout
checkout owned by another user, so the version would come out empty. owned by another user, so the version would come out empty. `ARG VERSION`
`ARG VERSION` has no default, and the build fails if the context carries has no default, and the build fails if the context carries `.git` and the
`.git` and the version still comes out empty, `dev` or `unknown`. A plain 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.