diff --git a/.gitea/workflows/cd.yml b/.gitea/workflows/cd.yml index 36e35b4..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 @@ -48,7 +56,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/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/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= diff --git a/scripts/tests/generated-child_test.sh b/scripts/tests/generated-child_test.sh new file mode 100755 index 0000000..5299466 --- /dev/null +++ b/scripts/tests/generated-child_test.sh @@ -0,0 +1,114 @@ +#!/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" + +# 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 + bad "$tool is available" "not on PATH — the generated child cannot be gated" + echo; echo "$pass passed, $fail failed"; exit 1 + 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' -o -name 'Dockerfile' \) \ + -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)"; rc=$? +if [ "$rc" -eq 0 ]; then + ok "templ generate succeeds in the generated child" +else + 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. +# 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)" + +# 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" +[ "$fail" -eq 0 ]