From b40db025cc6b133448e644ade8c1ba68f0783fac Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 17 Sep 2026 18:04:13 +0200 Subject: [PATCH 1/3] fix(template): ship a go.sum, and stop the templ generator drifting from the library Closes #8. `task check` did not pass on a clean checkout of this template, and had not for some time. Three defects in a chain, each hiding the next: 1. No go.sum at all. `go build ./...` failed on missing sum entries, so nothing downstream of it ever ran. 2. With that fixed, the real skew appeared: CI installs the templ GENERATOR as @latest while go.mod pinned the templ LIBRARY at v0.2.778. The v0.3 generator emits calls to templruntime.WriteString, which v0.2.778 does not have -- so generated code could not compile against the pinned runtime. Library moved to v0.3.1020 and the CI install now derives its version from go.mod, so generator and library cannot drift apart again. 3. .gitignore carried `*.templ.go`. templ writes `_templ.go` -- an underscore, not a dot -- so the pattern matched nothing. Generated files were kept out of git by luck rather than by the ignore rule. None of this was visible from this repo's CI, which has been green throughout, because the guard job skips `check` here entirely (#12). A template whose gate cannot run is a template whose defects are found by its children. Verified on a clean checkout: `go build ./...` clean, `task check` exit 0. Co-Authored-By: Claude Opus 5 --- .gitea/workflows/cd.yml | 9 ++++++++- .gitignore | 5 ++++- go.mod | 4 +--- go.sum | 4 ++++ 4 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 go.sum diff --git a/.gitea/workflows/cd.yml b/.gitea/workflows/cd.yml index 36e35b4..9af640c 100644 --- a/.gitea/workflows/cd.yml +++ b/.gitea/workflows/cd.yml @@ -48,7 +48,14 @@ jobs: - name: Install toolchain run: | go version - go install github.com/a-h/templ/cmd/templ@latest + # Pin the GENERATOR to the same version as the templ LIBRARY in + # go.mod. Installing @latest let them drift: the generator emitted + # calls (templruntime.WriteString) that the pinned library did not + # have, which is template-go-web#8. Nothing caught it because the + # guard skipped `check` in this repo entirely (#12). + templ_version="$(go list -m -f '{{.Version}}' github.com/a-h/templ)" + echo "templ library is $templ_version — installing the matching generator" + go install "github.com/a-h/templ/cmd/templ@$templ_version" curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh \ | sh -s -- -b "$(go env GOPATH)/bin" v2.11.4 diff --git a/.gitignore b/.gitignore index 902b7e7..756de3f 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,7 @@ go.work.sum # Project-specific bin/ -*.templ.go +# templ writes _templ.go -- this was '*.templ.go' (a dot, not an +# underscore) and therefore matched nothing, so generated files were only ever +# kept out of git by luck. template-go-web#8. +*_templ.go diff --git a/go.mod b/go.mod index 198b4ef..131d2d2 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,4 @@ module __MODULE_PATH__ go 1.26 -require ( - github.com/a-h/templ v0.2.778 -) +require github.com/a-h/templ v0.3.1020 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0857b6f --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= +github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -- 2.54.0 From 39b8e865404be416d5bfff661188ae335048fb34 Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 17 Sep 2026 18:05:23 +0200 Subject: [PATCH 2/3] fix(ci): let the template test itself, and cover the entrypoint ./... cannot see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #12. The guard that stops this template building itself also stopped it TESTING itself: check, build and deploy were all gated on is_template, which is always true here. So the repo that mints every other repo was the one repo with no working gate — and #8 sat in it for months with CI green throughout, found eventually by a child rather than by us. check is now ungated. build and deploy stay gated, because they genuinely cannot work here: placeholder module path, no image name, no namespace. Ungating alone would have been a false fix. `go build ./...`, `go vet ./...`, `go test ./...` and `golangci-lint run ./...` cannot see cmd/__PROJECT_NAME__ at all — Go skips directories whose names begin with an underscore when expanding package patterns: $ go list ./... __MODULE_PATH__/internal/web <- no cmd package So a newly-ungated check would still have skipped the package every generated repo is built around. scripts/tests/generated-child_test.sh substitutes the tracked tree into a throwaway child and gates the result: templ generate, go list, build, vet, golangci-lint. Ported from template-go-agent, plus a templ step, which also proves the pinned generator and pinned library agree. Proven to fail when it should. With a deliberate errcheck violation added to the entrypoint: generated-child harness -> FAIL cmd/childproj/main.go:12:26 errcheck golangci-lint run ./... -> "0 issues." The existing gate calls the same broken code clean. That contrast is the reason this harness exists rather than a wider lint invocation. Co-Authored-By: Claude Opus 5 --- .gitea/workflows/cd.yml | 10 ++- Taskfile.yml | 4 + scripts/tests/generated-child_test.sh | 102 ++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100755 scripts/tests/generated-child_test.sh diff --git a/.gitea/workflows/cd.yml b/.gitea/workflows/cd.yml index 9af640c..7d10c98 100644 --- a/.gitea/workflows/cd.yml +++ b/.gitea/workflows/cd.yml @@ -34,8 +34,16 @@ jobs: check: name: Lint / Test / Vet + # Deliberately NOT gated on the guard (#12). build and deploy are, because + # they genuinely cannot work here -- placeholder module path, no image name, + # no namespace. But linting and testing the template is meaningful, and + # gating it meant this repo had no working gate at all: #8 sat here with CI + # green throughout, and was found by a child rather than by us. + # + # `task check` includes scripts/tests/generated-child_test.sh, which covers + # the entrypoint that ./... cannot see -- ungating without that would run a + # gate that still skips the package every child is built around. needs: guard - if: needs.guard.outputs.is_template != 'true' runs-on: self-hosted steps: - uses: actions/checkout@v4 diff --git a/Taskfile.yml b/Taskfile.yml index 585f059..7cb85ec 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -27,6 +27,10 @@ tasks: # The template guard decides whether CI runs at all in every child repo, # so its regression test belongs in the same gate as the Go tests. - bash scripts/tests/is-template_test.sh + # The three Go commands above cannot see cmd/__PROJECT_NAME__ -- Go's + # ./... expansion skips directories whose names start with an underscore. + # This gates the substituted result, which is what a child actually runs. + - bash scripts/tests/generated-child_test.sh context:sync: desc: Regenerate all harness-specific context files diff --git a/scripts/tests/generated-child_test.sh b/scripts/tests/generated-child_test.sh new file mode 100755 index 0000000..8a22571 --- /dev/null +++ b/scripts/tests/generated-child_test.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Does a repo generated from this template pass its own gate? +# +# WHY THIS EXISTS +# +# `golangci-lint run ./...` and friends cannot see this template's entrypoint. +# Go skips directories whose names begin with "_" or "." when expanding package +# patterns, and the entrypoint lives in cmd/__PROJECT_NAME__: +# +# $ go list ./... +# __MODULE_PATH__/internal/web <- no cmd package +# +# After substitution the directory has a real name, becomes visible, and any +# defect in it appears — in the child, not here. That is how template-go-agent +# shipped a broken entrypoint for three months while its own CI stayed green +# (template-go-agent#1, #2). +# +# Linting the directory by name is not a fix: `golangci-lint run +# ./cmd/__PROJECT_NAME__` works, but reports formatter findings that do not +# exist in a generated repo, because gofumpt's import grouping depends on the +# module path and this one is a placeholder. +# +# So this substitutes the tracked tree into a throwaway child and gates the +# RESULT, which is the only artifact anyone actually runs. +# +# Usage: ./scripts/tests/generated-child_test.sh + +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +pass=0; fail=0 +ok() { printf ' ok %s\n' "$1"; pass=$((pass+1)); } +bad() { printf ' FAIL %s\n %s\n' "$1" "${2:-}"; fail=$((fail+1)); } + +echo "generated-child tests" + +for tool in golangci-lint templ; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo " SKIP $tool not installed — cannot gate the generated child" >&2 + exit 0 + fi +done + +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT + +# Tracked files from the WORKING TREE, not `git archive HEAD`: this runs inside +# `task check`, so it must gate what is about to be committed rather than the +# previous commit. `git ls-files` keeps the file set honest while taking +# current content. +( cd "$ROOT" && git ls-files -z ) | while IFS= read -r -d '' f; do + mkdir -p "$tmp/$(dirname "$f")" + cp "$ROOT/$f" "$tmp/$f" +done + +if [ ! -d "$tmp/cmd/__PROJECT_NAME__" ]; then + bad "the template still has a placeholder entrypoint directory" \ + "cmd/__PROJECT_NAME__ not found — has the layout changed?" + echo; echo "$pass passed, $fail failed"; exit 1 +fi +mv "$tmp/cmd/__PROJECT_NAME__" "$tmp/cmd/childproj" +find "$tmp" -type f \( -name '*.go' -o -name '*.templ' -o -name '*.mod' -o -name '*.yml' -o -name '*.md' \) \ + -exec sed -i \ + -e 's|__MODULE_PATH__|git.d-ma.be/mathias/childproj|g' \ + -e 's|__PROJECT_NAME__|childproj|g' {} + + +# Generated files are gitignored, so the child arrives without them — exactly as +# a freshly scaffolded repo does. Generating here also proves the pinned +# generator and the pinned library agree, which is template-go-web#8. +out="$(cd "$tmp" && templ generate 2>&1)" +if [ $? -eq 0 ]; then + ok "templ generate succeeds in the generated child" +else + bad "templ generate succeeds in the generated child" "$(printf '%s' "$out" | tail -3)" +fi + +# The whole point: the entrypoint must be visible to Go tooling in the child. +# If this regresses, everything below it passes for the wrong reason. +if ( cd "$tmp" && go list ./... 2>/dev/null ) | grep -q '/cmd/childproj$'; then + ok "the generated child's entrypoint is visible to go list" +else + bad "the generated child's entrypoint is visible to go list" \ + "cmd package missing from ./... — the child would be unchecked too" +fi + +out="$(cd "$tmp" && go build ./... 2>&1)" +[ -z "$out" ] && ok "the generated child builds" \ + || bad "the generated child builds" "$(printf '%s' "$out" | head -3)" + +out="$(cd "$tmp" && go vet ./... 2>&1)" +[ -z "$out" ] && ok "the generated child passes go vet" \ + || bad "the generated child passes go vet" "$(printf '%s' "$out" | head -3)" + +out="$(cd "$tmp" && golangci-lint run ./... 2>&1)" +if printf '%s' "$out" | grep -qE '^[0-9]+ issues:|issues:$'; then + bad "the generated child passes golangci-lint" "$(printf '%s' "$out" | grep -E '\.go:[0-9]+' | head -3)" +else + ok "the generated child passes golangci-lint" +fi + +echo +echo "$pass passed, $fail failed" +[ "$fail" -eq 0 ] -- 2.54.0 From 2c4158a80860d5d3571c74351c34e69edb00a70f Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 17 Sep 2026 18:14:23 +0200 Subject: [PATCH 3/3] fix(test): grade golangci-lint on its exit code, not on grepping its output Addresses the blocking finding from the review of this PR. The harness matched golangci-lint's human-readable summary to decide pass or fail, and discarded its exit status. Any failure that is not "found issues" -- bad config, unwritable TMPDIR, OOM, a future change to the output format -- produces non-matching text and was scored `ok`. Since this harness is the only gate covering cmd/__PROJECT_NAME__, a linter that dies would have left the entrypoint unchecked with CI green. That is the precise defect class this file was added to end, which is why it blocked the PR rather than being filed as a follow-up. golangci-lint exits 0 clean / 1 issues found / >1 the linter itself failed. Now graded on that, and the >1 case reports the child as UNCHECKED rather than clean. Two more from the same review: - a missing tool now fails instead of `exit 0` with zero assertions, which was indistinguishable from a real pass to anything reading the exit code. It was unreachable in CI only by luck of task ordering, not by rule. - Dockerfile joins the substituted file set. It builds ./cmd/__PROJECT_NAME__ and was outside the pattern, so the harness never validated the one path a repo rename has historically broken here. Verified by flipping each case: crashing linter (exit 3) -> FAIL "the child is UNCHECKED, not clean" tools absent -> FAIL, 0 passed normal -> 5 passed, 0 failed Co-Authored-By: Claude Opus 5 --- scripts/tests/generated-child_test.sh | 36 ++++++++++++++++++--------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/scripts/tests/generated-child_test.sh b/scripts/tests/generated-child_test.sh index 8a22571..5299466 100755 --- a/scripts/tests/generated-child_test.sh +++ b/scripts/tests/generated-child_test.sh @@ -34,10 +34,13 @@ bad() { printf ' FAIL %s\n %s\n' "$1" "${2:-}"; fail=$((fail+1)); } echo "generated-child tests" +# A missing tool is a FAILURE, not a skip. An `exit 0` with zero assertions is +# indistinguishable from a real pass to anything reading the exit code -- which +# is the whole failure mode this harness exists to close. for tool in golangci-lint templ; do if ! command -v "$tool" >/dev/null 2>&1; then - echo " SKIP $tool not installed — cannot gate the generated child" >&2 - exit 0 + bad "$tool is available" "not on PATH — the generated child cannot be gated" + echo; echo "$pass passed, $fail failed"; exit 1 fi done @@ -58,7 +61,7 @@ if [ ! -d "$tmp/cmd/__PROJECT_NAME__" ]; then echo; echo "$pass passed, $fail failed"; exit 1 fi mv "$tmp/cmd/__PROJECT_NAME__" "$tmp/cmd/childproj" -find "$tmp" -type f \( -name '*.go' -o -name '*.templ' -o -name '*.mod' -o -name '*.yml' -o -name '*.md' \) \ +find "$tmp" -type f \( -name '*.go' -o -name '*.templ' -o -name '*.mod' -o -name '*.yml' -o -name '*.md' -o -name 'Dockerfile' \) \ -exec sed -i \ -e 's|__MODULE_PATH__|git.d-ma.be/mathias/childproj|g' \ -e 's|__PROJECT_NAME__|childproj|g' {} + @@ -66,11 +69,11 @@ find "$tmp" -type f \( -name '*.go' -o -name '*.templ' -o -name '*.mod' -o -name # Generated files are gitignored, so the child arrives without them — exactly as # a freshly scaffolded repo does. Generating here also proves the pinned # generator and the pinned library agree, which is template-go-web#8. -out="$(cd "$tmp" && templ generate 2>&1)" -if [ $? -eq 0 ]; then +out="$(cd "$tmp" && templ generate 2>&1)"; rc=$? +if [ "$rc" -eq 0 ]; then ok "templ generate succeeds in the generated child" else - bad "templ generate succeeds in the generated child" "$(printf '%s' "$out" | tail -3)" + bad "templ generate succeeds in the generated child" "exit $rc: $(printf '%s' "$out" | tail -2)" fi # The whole point: the entrypoint must be visible to Go tooling in the child. @@ -90,12 +93,21 @@ out="$(cd "$tmp" && go vet ./... 2>&1)" [ -z "$out" ] && ok "the generated child passes go vet" \ || bad "the generated child passes go vet" "$(printf '%s' "$out" | head -3)" -out="$(cd "$tmp" && golangci-lint run ./... 2>&1)" -if printf '%s' "$out" | grep -qE '^[0-9]+ issues:|issues:$'; then - bad "the generated child passes golangci-lint" "$(printf '%s' "$out" | grep -E '\.go:[0-9]+' | head -3)" -else - ok "the generated child passes golangci-lint" -fi +# Graded on the EXIT CODE, never on grepping the output. golangci-lint exits +# 0 clean / 1 issues found / >1 the linter itself failed. Matching its +# human-readable summary instead scores a CRASHED linter as a pass -- bad +# config, unwritable TMPDIR, OOM, or simply a future change to its output +# format all produce non-matching text. Since this harness is the only gate +# covering the entrypoint, that would leave cmd/__PROJECT_NAME__ unchecked with +# CI green, which is precisely the defect class this file exists to end. +out="$(cd "$tmp" && golangci-lint run ./... 2>&1)"; rc=$? +case "$rc" in + 0) ok "the generated child passes golangci-lint" ;; + 1) bad "the generated child passes golangci-lint" \ + "$(printf '%s' "$out" | grep -E '\.go:[0-9]+' | head -3)" ;; + *) bad "golangci-lint ran successfully" \ + "linter exited $rc — the child is UNCHECKED, not clean: $(printf '%s' "$out" | tail -2)" ;; +esac echo echo "$pass passed, $fail failed" -- 2.54.0