fix: lifecycle methods need value receivers - app detail page 500 (v0.158.1)

Shipped in v0.158.0, caught live within the hour. /apps/<slug> returned 500
for EVERY app: html/template cannot call a pointer-receiver method on a
non-addressable value, and appDetailHandler passes Meta as a VALUE inside a
map[string]interface{}.

It compiled and every test passed because nothing rendered app_info - the
catalog tests used the funcmap route, which takes a value and works either
way. A template method call is only checked when the template runs.

Adds TestAppInfoRendersForEveryLifecycle with the handler's exact data shape.
This commit is contained in:
2026-07-21 16:33:00 +02:00
parent 5fdd2039fd
commit 0fbd272ad2
3 changed files with 95 additions and 3 deletions
+10 -3
View File
@@ -220,7 +220,7 @@ const (
// or a state added in a later release, silently pull a working app out of every customer's catalog.
// The gate that actually protects against installing something is `CanInstall`, and it is fed by
// this same function, so the two can never disagree.
func (m *Metadata) EffectiveLifecycle() string {
func (m Metadata) EffectiveLifecycle() string {
switch m.Lifecycle {
case "", LifecycleAvailable:
return LifecycleAvailable
@@ -233,13 +233,20 @@ func (m *Metadata) EffectiveLifecycle() string {
}
}
// VALUE receivers on all three, deliberately: html/template cannot call a POINTER-receiver method
// on a non-addressable value, and `.Meta` reaches the templates as a stacks.Metadata VALUE inside a
// map[string]interface{}. With a pointer receiver `{{if .Meta.IsAbandoned}}` fails at RENDER time
// with "can't evaluate field IsAbandoned in type interface {}" — which is a 500 on the page, not a
// compile error, so nothing catches it until someone loads the page. That is exactly how it shipped
// once (v0.158.0, caught live the same hour). Keep these value receivers.
//
// CanInstall reports whether this app may be offered/installed. The catalog listing and the deploy
// endpoint MUST both use this — a template excluded from the list but accepted by a direct POST
// would be a gate in name only.
func (m *Metadata) CanInstall() bool { return m.EffectiveLifecycle() == LifecycleAvailable }
func (m Metadata) CanInstall() bool { return m.EffectiveLifecycle() == LifecycleAvailable }
// IsAbandoned reports whether a DEPLOYED instance should carry the "no longer maintained" notice.
func (m *Metadata) IsAbandoned() bool { return m.EffectiveLifecycle() == LifecycleAbandoned }
func (m Metadata) IsAbandoned() bool { return m.EffectiveLifecycle() == LifecycleAbandoned }
// LoadMetadata reads .felhom.yml from a stack directory.
// Returns default metadata if the file doesn't exist.