fix: green baseline, and let the template test itself #13

Merged
mathias merged 3 commits from fix/green-baseline-and-self-test into main 2026-09-17 16:16:38 +00:00
Owner

Closes #8. Closes #12. PR because #12 is risk/medium.

Two commits, one causal chain: this template could not test itself, so it accumulated defects that only its children ever saw.

What task check actually did on a clean checkout

It failed. Three defects, each hiding the next:

  1. No go.sum at all — go build ./... died on missing sum entries, so nothing downstream 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 templruntime.WriteString, which v0.2.778 does not have. Generated code could not compile against the pinned runtime.
  3. .gitignore carried *.templ.go. templ writes <name>_templ.go — underscore, not dot — so the pattern matched nothing, and generated files stayed out of git by luck rather than by rule.

Fixed: library at v0.3.1020, and the CI install now derives its version from go.mod so generator and library cannot drift apart again. go build ./... clean, task check exit 0.

Why none of it was visible

The guard job gated check as well as build and deploy, and is_template is always true here. The repo that mints every other repo had no working gate. #8 sat in it with CI green throughout and was found by a child.

check is now ungated. build and deploy stay gated — they genuinely cannot work against a placeholder module path with no image name and no namespace.

Ungating alone would have been a false fix

go build ./..., go vet ./..., go test ./... and golangci-lint run ./... cannot see this template's entrypoint. Go skips directories whose names begin with _ when expanding package patterns, and it lives in cmd/__PROJECT_NAME__:

$ go list ./...
__MODULE_PATH__/internal/web        <- no cmd package

So a freshly-ungated check would still have skipped the one package every generated repo is built around. That is exactly how template-go-agent shipped a broken entrypoint for three months with its own CI green (template-go-agent#1, #2, fixed today in 2b3851a).

Linting the directory by name is not the answer either: golangci-lint run ./cmd/__PROJECT_NAME__ works, but reports gofumpt findings that do not exist in a child, because import grouping depends on the module path and this one is a placeholder. That route turns the gate red over an artifact of being a template.

The fix: gate the thing that actually ships

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 with a templ step, which also proves the pinned generator and library agree.

It reads tracked files from the working tree, not git archive HEAD: it runs inside task check, so it must gate what is about to be committed. The agent version reported a stale pass against an unstaged fix on first use, which is how that was found.

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 whole argument for this harness over a wider lint invocation.

Verification

task check exit 0, zero FAIL lines, both harnesses running: is-template 8/8, generated-child 5/5.

Not in scope

local-dev#33 — repos generated before today still carry the old guard and their green runs never built anything. This stops new children being born broken; it does not find the existing ones.

🤖 Generated with Claude Code


Created via git-mcp on behalf of @mathias

Closes #8. Closes #12. PR because #12 is `risk/medium`. Two commits, one causal chain: this template could not test itself, so it accumulated defects that only its children ever saw. ## What `task check` actually did on a clean checkout It failed. Three defects, each hiding the next: 1. **No `go.sum` at all** — `go build ./...` died on missing sum entries, so nothing downstream 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 `templruntime.WriteString`, which v0.2.778 does not have. Generated code could not compile against the pinned runtime. 3. `.gitignore` carried `*.templ.go`. templ writes `<name>_templ.go` — **underscore, not dot** — so the pattern matched nothing, and generated files stayed out of git by luck rather than by rule. Fixed: library at `v0.3.1020`, and the CI install now derives its version from `go.mod` so generator and library cannot drift apart again. `go build ./...` clean, `task check` exit 0. ## Why none of it was visible The guard job gated `check` as well as `build` and `deploy`, and `is_template` is always true here. **The repo that mints every other repo had no working gate.** #8 sat in it with CI green throughout and was found by a child. `check` is now ungated. `build` and `deploy` stay gated — they genuinely cannot work against a placeholder module path with no image name and no namespace. ## Ungating alone would have been a false fix `go build ./...`, `go vet ./...`, `go test ./...` and `golangci-lint run ./...` **cannot see this template's entrypoint**. Go skips directories whose names begin with `_` when expanding package patterns, and it lives in `cmd/__PROJECT_NAME__`: ``` $ go list ./... __MODULE_PATH__/internal/web <- no cmd package ``` So a freshly-ungated `check` would still have skipped the one package every generated repo is built around. That is exactly how `template-go-agent` shipped a broken entrypoint for three months with its own CI green (`template-go-agent#1`, `#2`, fixed today in `2b3851a`). Linting the directory by name is not the answer either: `golangci-lint run ./cmd/__PROJECT_NAME__` works, but reports gofumpt findings that do not exist in a child, because import grouping depends on the module path and this one is a placeholder. That route turns the gate red over an artifact of being a template. ## The fix: gate the thing that actually ships `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` with a templ step, which also proves the pinned generator and library agree. It reads tracked files from the **working tree**, not `git archive HEAD`: it runs inside `task check`, so it must gate what is about to be committed. The agent version reported a stale pass against an unstaged fix on first use, which is how that was found. ## 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 whole argument for this harness over a wider lint invocation. ## Verification `task check` exit 0, zero FAIL lines, both harnesses running: `is-template` 8/8, `generated-child` 5/5. ## Not in scope `local-dev#33` — repos generated before today still carry the old guard and their green runs never built anything. This stops new children being born broken; it does not find the existing ones. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Created via git-mcp on behalf of @mathias_
mathias added 2 commits 2026-09-17 16:05:43 +00:00
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 `<name>_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 <noreply@anthropic.com>
fix(ci): let the template test itself, and cover the entrypoint ./... cannot see
CD / Detect unsubstituted template (pull_request) Successful in 1s
CD / Lint / Test / Vet (pull_request) Successful in 8s
CD / Build & Import (pull_request) Skipped
CD / Deploy via GitOps (pull_request) Skipped
39b8e86540
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 <noreply@anthropic.com>
Author
Owner

Review: blocked on one finding — the new harness grades a crashed linter as a pass

I verified every claim in the body independently, and the substance holds up: go list ./... really is blind to cmd/__PROJECT_NAME__, the wide lint really does say 0 issues. on a tree with a live errcheck violation, the templ generator really is derived from go.mod, and check really did run for the first time (run 7511, job 11201, Lint / Test / Vet → success; Build & Import and Deploy via GitOps → skipped). Details at the bottom.

But I flipped the new harness five ways, and one flip it should have caught, it passed.

Finding (blocking) — scripts/tests/generated-child_test.sh:93-98

The golangci-lint result is graded by grepping the tool's stdout prose; its exit code is discarded. Any golangci-lint invocation that dies for a reason other than finding issues — bad config, cache/$TMPDIR permission error, OOM, a future output-format change — produces output that does not match '^[0-9]+ issues:|issues:$', and the harness therefore calls it ok.

Reproduced by putting a shim on PATH that exits 3 and prints a fatal error instead of a findings list:

$ PATH=/tmp/shimA:$PATH bash scripts/tests/generated-child_test.sh
generated-child tests
  ok   templ generate succeeds in the generated child
  ok   the generated child's entrypoint is visible to go list
  ok   the generated child builds
  ok   the generated child passes go vet
  ok   the generated child passes golangci-lint     <-- linter exited 3, "FATAL: golangci-lint failed to run"

5 passed, 0 failed
HARNESS EXIT=0

This matters more here than it normally would, because this harness is the only gate that covers the entrypoint. If it rubber-stamps, the package every child repo is built around goes unchecked again — and the check job stays green while doing it. That is the same shape as the defect this PR exists to remove: a gate that passes without proving anything.

golangci-lint exits 0 clean / 1 on issues / >1 on error, so the correction is to assert on the exit status rather than the message:

out="$(cd "$tmp" && golangci-lint run ./... 2>&1)"; rc=$?
if [ "$rc" -eq 0 ]; then
  ok "the generated child passes golangci-lint"
else
  bad "the generated child passes golangci-lint" "$(printf '%s' "$out" | grep -E '\.go:[0-9]+' | head -3)"
fi

Second finding (non-blocking, but please fix in the same push) — :37-42

Missing tooling is a silent success:

$ env PATH=/tmp/empty:/usr/bin:/bin bash scripts/tests/generated-child_test.sh
generated-child tests
  SKIP golangci-lint not installed — cannot gate the generated child
HARNESS EXIT=0

Zero assertions, exit 0, and the SKIP goes to stderr so the body's own success criterion ("exit 0, zero FAIL lines") cannot distinguish it from a real 5/5.

In the CI path this is currently unreachable — task check runs templ generate and golangci-lint run ./... before the harnesses, so a missing tool fails earlier. But it is unreachable by luck, not by rule, and the Install toolchain step has a matching hole: curl -sSfL ... | sh -s -- -b ... v2.11.4 runs without pipefail, so if the curl fails, sh reads empty input and the step still succeeds having installed nothing. A missing tool should be bad + nonzero, not exit 0.

Third (note only, no action needed in this PR) — :61-64

The substitution covers *.go *.templ *.mod *.yml *.md. Three tracked files carry placeholders and are outside that set: Dockerfile, .context/system-prompt.txt, and the harness itself. Dockerfile:8 builds ./cmd/__PROJECT_NAME__, so the harness does not validate the one path that a repo rename has historically broken in this estate. The real scaffolder substitutes every file, so children are fine — this is a coverage gap in the gate, not a live bug. Worth its own issue.


What I verified, and how

Claim 1 — go.sum + templ pin. Confirmed derived, not @latest. CI log, job 11201:

templ library is v0.3.1020 — installing the matching generator

Reintroducing the skew (go.mod → v0.2.778) turns the harness red: 1 passed, 4 failed. Deleting go.sum likewise: 1 passed, 4 failed. The .gitignore fix is real too — after templ generate, git status --porcelain is empty, so *_templ.go is now ignored by rule rather than by luck.

Claim 2 — ungating. Run 7511 on head 39b8e86, the only run for that SHA:

Detect unsubstituted template -> success
Lint / Test / Vet             -> success   <-- first time this repo gated itself
Build & Import                -> skipped
Deploy via GitOps             -> skipped

No SKIP line anywhere in the log; both harnesses genuinely executed (is-template 8/8, generated-child 5/5). build/deploy remain correctly gated on is_template != 'true'.

Claim 3 — the entrypoint blind spot. Confirmed on the branch:

$ go list ./...
__MODULE_PATH__/internal/web          # no cmd package

With _, _ = w.Write(...) changed to w.Write(...) in the entrypoint, and templ generated:

$ bash scripts/tests/generated-child_test.sh
  FAIL the generated child passes golangci-lint
     cmd/childproj/main.go:17:10: Error return value of `w.Write` is not checked (errcheck)
  4 passed, 1 failed          (exit 1)

$ golangci-lint run ./...
0 issues.                     (exit 0)

The contrast in the body reproduces exactly. (Note it only reproduces after templ generate; on a tree without generated files the wide lint fails on typecheck instead.)

#12's done-when clauses. Clause 1, demonstrated by breaking rather than reading — I narrowed the guard's regex to a literal placeholder token and scripts/tests/is-template_test.sh went 4 passed, 4 failed, exit 1; since check is now ungated and runs that harness, this would now fail the repo's CI. Clause 2, satisfied per run 7511 above. Clause 3, I substituted the tracked tree into a real child (mysvc): guard answers false, go list ./... now shows cmd/mysvc, and the harness still passes 5/5 there.

#8. Both halves fixed and both regression-tested. Verified empirically, not by reading the diff.

No secrets in the diff. task check locally reproduces CI exactly: 0 issues., 8/8, 5/5.


Everything except the first finding is in good shape, and this is a clear improvement on main (which does not build). I am blocking only on :93-98, because a gate that reports success when its checker crashed is the specific failure this PR is meant to end, and the fix is three lines rather than a design decision. Push the exit-code assertion and I will re-run the flip and re-gate.

## Review: blocked on one finding — the new harness grades a *crashed* linter as a pass I verified every claim in the body independently, and the substance holds up: `go list ./...` really is blind to `cmd/__PROJECT_NAME__`, the wide lint really does say `0 issues.` on a tree with a live `errcheck` violation, the templ generator really is derived from `go.mod`, and `check` really did run for the first time (run **7511**, job **11201**, `Lint / Test / Vet` → success; `Build & Import` and `Deploy via GitOps` → skipped). Details at the bottom. But I flipped the new harness five ways, and **one flip it should have caught, it passed.** ### Finding (blocking) — `scripts/tests/generated-child_test.sh:93-98` The golangci-lint result is graded by grepping the tool's **stdout prose**; its **exit code is discarded**. Any golangci-lint invocation that dies for a reason other than finding issues — bad config, cache/`$TMPDIR` permission error, OOM, a future output-format change — produces output that does not match `'^[0-9]+ issues:|issues:$'`, and the harness therefore calls it `ok`. Reproduced by putting a shim on `PATH` that exits 3 and prints a fatal error instead of a findings list: ``` $ PATH=/tmp/shimA:$PATH bash scripts/tests/generated-child_test.sh generated-child tests ok templ generate succeeds in the generated child ok the generated child's entrypoint is visible to go list ok the generated child builds ok the generated child passes go vet ok the generated child passes golangci-lint <-- linter exited 3, "FATAL: golangci-lint failed to run" 5 passed, 0 failed HARNESS EXIT=0 ``` This matters more here than it normally would, because this harness is the **only** gate that covers the entrypoint. If it rubber-stamps, the package every child repo is built around goes unchecked again — and the `check` job stays green while doing it. That is the same shape as the defect this PR exists to remove: a gate that passes without proving anything. `golangci-lint` exits 0 clean / 1 on issues / >1 on error, so the correction is to assert on the exit status rather than the message: ```bash out="$(cd "$tmp" && golangci-lint run ./... 2>&1)"; rc=$? if [ "$rc" -eq 0 ]; then ok "the generated child passes golangci-lint" else bad "the generated child passes golangci-lint" "$(printf '%s' "$out" | grep -E '\.go:[0-9]+' | head -3)" fi ``` ### Second finding (non-blocking, but please fix in the same push) — `:37-42` Missing tooling is a silent success: ``` $ env PATH=/tmp/empty:/usr/bin:/bin bash scripts/tests/generated-child_test.sh generated-child tests SKIP golangci-lint not installed — cannot gate the generated child HARNESS EXIT=0 ``` Zero assertions, exit 0, and the SKIP goes to **stderr** so the body's own success criterion ("exit 0, zero FAIL lines") cannot distinguish it from a real 5/5. In the CI path this is currently unreachable — `task check` runs `templ generate` and `golangci-lint run ./...` *before* the harnesses, so a missing tool fails earlier. But it is unreachable by luck, not by rule, and the `Install toolchain` step has a matching hole: `curl -sSfL ... | sh -s -- -b ... v2.11.4` runs without `pipefail`, so if the curl fails, `sh` reads empty input and the step still succeeds having installed nothing. A missing tool should be `bad` + nonzero, not `exit 0`. ### Third (note only, no action needed in this PR) — `:61-64` The substitution covers `*.go *.templ *.mod *.yml *.md`. Three tracked files carry placeholders and are outside that set: `Dockerfile`, `.context/system-prompt.txt`, and the harness itself. `Dockerfile:8` builds `./cmd/__PROJECT_NAME__`, so the harness does not validate the one path that a repo rename has historically broken in this estate. The real scaffolder substitutes every file, so children are fine — this is a coverage gap in the gate, not a live bug. Worth its own issue. --- ### What I verified, and how **Claim 1 — go.sum + templ pin.** Confirmed derived, not `@latest`. CI log, job 11201: ``` templ library is v0.3.1020 — installing the matching generator ``` Reintroducing the skew (`go.mod` → `v0.2.778`) turns the harness red: `1 passed, 4 failed`. Deleting `go.sum` likewise: `1 passed, 4 failed`. The `.gitignore` fix is real too — after `templ generate`, `git status --porcelain` is empty, so `*_templ.go` is now ignored by rule rather than by luck. **Claim 2 — ungating.** Run 7511 on head `39b8e86`, the only run for that SHA: ``` Detect unsubstituted template -> success Lint / Test / Vet -> success <-- first time this repo gated itself Build & Import -> skipped Deploy via GitOps -> skipped ``` No `SKIP` line anywhere in the log; both harnesses genuinely executed (`is-template` 8/8, `generated-child` 5/5). `build`/`deploy` remain correctly gated on `is_template != 'true'`. **Claim 3 — the entrypoint blind spot.** Confirmed on the branch: ``` $ go list ./... __MODULE_PATH__/internal/web # no cmd package ``` With `_, _ = w.Write(...)` changed to `w.Write(...)` in the entrypoint, and templ generated: ``` $ bash scripts/tests/generated-child_test.sh FAIL the generated child passes golangci-lint cmd/childproj/main.go:17:10: Error return value of `w.Write` is not checked (errcheck) 4 passed, 1 failed (exit 1) $ golangci-lint run ./... 0 issues. (exit 0) ``` The contrast in the body reproduces exactly. (Note it only reproduces *after* `templ generate`; on a tree without generated files the wide lint fails on typecheck instead.) **#12's done-when clauses.** Clause 1, demonstrated by breaking rather than reading — I narrowed the guard's regex to a literal placeholder token and `scripts/tests/is-template_test.sh` went `4 passed, 4 failed`, exit 1; since `check` is now ungated and runs that harness, this would now fail the repo's CI. Clause 2, satisfied per run 7511 above. Clause 3, I substituted the tracked tree into a real child (`mysvc`): guard answers `false`, `go list ./...` now shows `cmd/mysvc`, and the harness still passes 5/5 there. **#8.** Both halves fixed and both regression-tested. Verified empirically, not by reading the diff. No secrets in the diff. `task check` locally reproduces CI exactly: `0 issues.`, 8/8, 5/5. --- Everything except the first finding is in good shape, and this is a clear improvement on `main` (which does not build). I am blocking only on `:93-98`, because a gate that reports success when its checker crashed is the specific failure this PR is meant to end, and the fix is three lines rather than a design decision. Push the exit-code assertion and I will re-run the flip and re-gate.
mathias added 1 commit 2026-09-17 16:14:25 +00:00
fix(test): grade golangci-lint on its exit code, not on grepping its output
CD / Detect unsubstituted template (pull_request) Successful in 0s
CD / Lint / Test / Vet (pull_request) Successful in 5s
CD / Build & Import (pull_request) Skipped
CD / Deploy via GitOps (pull_request) Skipped
CD / Detect unsubstituted template (push) Successful in 0s
CD / Lint / Test / Vet (push) Successful in 5s
CD / Build & Import (push) Skipped
CD / Deploy via GitOps (push) Skipped
2c4158a808
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 <noreply@anthropic.com>
mathias merged commit 2c4158a808 into main 2026-09-17 16:16:38 +00:00
Sign in to join this conversation.