fix(installer): clear legacy IMAGE_TAG so upgraders aren't pinned to v1.0.0

The v1.0.0 .env.production.example template hard-coded IMAGE_TAG=v1.0.0.
Merge() preserved any non-empty existing value, so re-running the
installer after the template was changed to IMAGE_TAG= still left the
stale pin in .env, silently locking docker compose to the old release.

Introduce a small templateAuthoritativeKeys set: when the template
defines such a key (even as empty), the template wins. Existing
non-empty user values are still preserved if a future template drops
the key entirely. IMAGE_TAG is the only entry today.

Adds two regression tests covering both directions.
This commit is contained in:
0sm0s1z
2026-04-22 15:36:01 -07:00
parent f5a9e52d08
commit 6a9a4a6fab
2 changed files with 59 additions and 0 deletions
+20
View File
@@ -19,12 +19,32 @@ type Options struct {
CORSAllowedOrigin string
}
// templateAuthoritativeKeys are keys whose template definition always wins,
// even when the user's existing .env has a non-empty value. This exists so
// that intentional template changes (e.g. clearing IMAGE_TAG so compose can
// resolve the default `latest` tag) propagate to upgraders. Without this,
// stale values written by older template versions become permanently sticky
// because Merge otherwise prefers any non-empty existing value.
//
// Only add a key here when the template is the source of truth for it AND
// shipping a stale legacy value would break upgrades. IMAGE_TAG is the
// canonical case: v1.0.0's template hard-coded IMAGE_TAG=v1.0.0, which
// pinned every upgrader to that release until manually edited.
var templateAuthoritativeKeys = map[string]struct{}{
"IMAGE_TAG": {},
}
func Merge(templateVals, existingVals map[string]string, opts Options) map[string]string {
out := make(map[string]string, len(templateVals)+len(existingVals)+8)
for k, v := range templateVals {
out[k] = v
}
for k, v := range existingVals {
if _, authoritative := templateAuthoritativeKeys[k]; authoritative {
if _, definedInTemplate := templateVals[k]; definedInTemplate {
continue
}
}
if strings.TrimSpace(v) != "" {
out[k] = v
}
@@ -127,6 +127,45 @@ func TestEnsureRequired_PreservesManualDatabaseURL(t *testing.T) {
}
}
func TestMerge_ClearsLegacyImageTagFromExistingEnv(t *testing.T) {
// Regression: v1.0.0's template hard-coded IMAGE_TAG=v1.0.0. The current
// template ships IMAGE_TAG= (empty) so docker-compose.yaml's `:-latest`
// fallback wins. Without IMAGE_TAG being template-authoritative the old
// value stays sticky and pins upgraders to v1.0.0 forever.
template := map[string]string{
"IMAGE_TAG": "",
"POSTGRES_PASSWORD": "change-me-strong-db-password",
}
existing := map[string]string{
"IMAGE_TAG": "v1.0.0",
"POSTGRES_PASSWORD": "user-set-password",
}
out := Merge(template, existing, Options{})
if got := out["IMAGE_TAG"]; got != "" {
t.Errorf("IMAGE_TAG = %q, want empty (template-authoritative)", got)
}
if got := out["POSTGRES_PASSWORD"]; got != "user-set-password" {
t.Errorf("POSTGRES_PASSWORD = %q, want user-set value preserved", got)
}
}
func TestMerge_PreservesImageTagWhenTemplateOmits(t *testing.T) {
// If a future template drops IMAGE_TAG entirely, an explicit pin in the
// user's .env should still survive (no template intent to clear).
template := map[string]string{
"POSTGRES_PASSWORD": "change-me",
}
existing := map[string]string{
"IMAGE_TAG": "v1.2.3",
}
out := Merge(template, existing, Options{})
if got := out["IMAGE_TAG"]; got != "v1.2.3" {
t.Errorf("IMAGE_TAG = %q, want v1.2.3 preserved", got)
}
}
func TestEnsureRequired_SetsInternalAPIKeyFileDefault(t *testing.T) {
values := map[string]string{
"POSTGRES_PASSWORD": "abcdef1234567890abcdef1234567890",