fix(config): update config when the local version is unusable - #155
fix(config): update config when the local version is unusable#155ziomizar wants to merge 4 commits into
Conversation
Update() compares the local config's metadata.version against the newly fetched one and returns an error if either will not parse. The remote side is guarded on being non-empty; the local side is not, so a config whose metadata has a url but no version fails with could not compare config versions: invalid semantic version The failure is permanent. The error returns before the file is rewritten, so the unusable version stays on disk and every later run fails identically. With the check running from PersistentPreRun, that means the message on every command and a config that never refreshes again. Such configs are not hand-made: processConfig() adds metadata.url and downloaded_at to whatever it fetches, so any config installed by config:install or by an earlier auto-refresh, from a served config that carried no version, has exactly this shape. When the publisher later starts serving a version - which is the point of the field - every one of those installs breaks at once, and only a reinstall recovers it. A local version that will not parse says nothing about whether the fetched config is newer, so treat it as out of date and let the update proceed. The rewritten file carries the new version, so the next run compares cleanly. The parse failure is logged for debugging instead of returned. Found on a vendor CLI whose served config gained a metadata.version after its installs were already in the field.
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adjusts config auto-update behavior to allow updates to proceed when the local config version is missing or unparsable, preventing permanent failure loops.
Changes:
- Change
Updateto log version-compare failures instead of returning an error, allowing updates to proceed. - Update existing tests to assert successful update when the local version is invalid.
- Add a new test covering multiple “unusable local version” formats to ensure the config file is rewritten with a usable version.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| internal/config/alt/update.go | Changes version comparison handling to be non-fatal and proceed with updating. |
| internal/config/alt/update_test.go | Updates and adds tests to validate update behavior with unusable local version strings. |
Suppressed comments (1)
internal/config/alt/update.go:85
- The new behavior proceeds with an update on any version comparison error, including cases where the remote/new version is invalid/unparsable. That can cause applying a config that cannot be ordered relative to the current one (and potentially overwriting a valid local config with an invalid/older one), especially when
UpdatedAtis unset. Consider only ignoring comparison errors when the local version is unusable but the new version is parseable; if the new version is unusable, return an error (or require a newerUpdatedAt) instead of updating.
if newCnfStruct.Metadata.Version != "" && cnf.Metadata.Version != "" {
// A local version that will not parse cannot say anything about whether the
// new config is newer, so treat it as out of date and let the update below
// replace it. Failing here instead would be permanent: the error returns
// before the file is rewritten, so the unusable version stays on disk and
// every subsequent run fails the same way.
cmp, err := version.Compare(cnf.Metadata.Version, newCnfStruct.Metadata.Version)
if err != nil {
debugLog("Could not compare config versions (local %q, new %q): %s",
cnf.Metadata.Version, newCnfStruct.Metadata.Version, err)
} else if cmp >= 0 {
debugLog("Config is already up to date (version %s)", cnf.Metadata.Version)
return nil
}
}
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Three points from review: - The comment described the unusable-local-version case, but the code treated any comparison error the same way, including an unusable new version. Validate the local version explicitly so the two cases are distinct, and keep the error for a new version that cannot be compared rather than applying that config. FromYAML already rejects such a version while the config is being fetched, so the error is now a guard against an invariant that lives in another package. - The tests set the state directory override with os.Setenv and never restored it, which leaked into later tests in the package. Use t.Setenv instead, in the new test and in TestUpdate, which had the same problem already. - Add TestUpdateWithUnusableNewVersion for the other side of the comparison: a served version that will not parse must never be applied. It passes on main too, which is the point - it records behaviour that already holds so a later change cannot quietly start applying such configs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (2)
internal/config/alt/update_test.go:94
- The test asserts against
lastLogged, which makes it brittle ifalt.Updateadds any later log line (e.g., from deferred state saving or additional debug output). Prefer capturing all log messages (e.g., append to a slice or write to a buffer) and asserting that any logged line contains"Automatically updated config file".
cnf.Metadata.Version = "invalid"
err = alt.Update(ctx, cnf, logger)
assert.NoError(t, err)
assert.Contains(t, lastLogged, "Automatically updated config file")
internal/config/alt/update_test.go:169
- Similar to
TestUpdate, asserting on only the final logged message is fragile and can fail if logging order changes or additional logs are added after the update log. Collect logs (slice/buffer) and assert the update message appears at least once.
var lastLogged string
err = alt.Update(config.ToContext(context.Background(), cnf), cnf,
func(msg string, args ...any) { lastLogged = fmt.Sprintf(msg, args...) })
assert.NoError(t, err)
assert.Contains(t, lastLogged, "Automatically updated config file")
gocritic's appendAssign flagged appending to testConfig and assigning the result elsewhere. testConfig is embedded once and shared by every test in the package, so an append with spare capacity could write into its backing array, and the call site was inside a loop over subtests that would then alias each other. Add testConfigWithMetadata, which copies before appending, and use it for the three places that build a config with a metadata section.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (2)
internal/config/alt/update_test.go:94
- This still appends directly to
remoteConfig(which earlier is typically derived fromtestConfig). IftestConfighas spare capacity,appendcan mutate its backing array and leak changes across tests. Prefer using the newtestConfigWithMetadata(...)helper here to ensure a fresh copy is created before appending metadata.
remoteConfig = append(remoteConfig, []byte("\nmetadata: {version: 1.0.1}")...)
// A local version that cannot be parsed says nothing about whether the new
// config is newer, so the update proceeds rather than failing.
cnf.Metadata.Version = "invalid"
err = alt.Update(ctx, cnf, logger)
assert.NoError(t, err)
assert.Contains(t, lastLogged, "Automatically updated config file")
internal/config/alt/update_test.go:163
- These tests rely on setting the file mtime to
now - 1hto bypass Update’sCheckIntervalshort-circuit. If the configuredcnf.Updates.CheckIntervalis ever > 1 hour, the update will be skipped and the test will fail. Set the mtime relative to the configured interval instead (e.g.,now - (interval + small buffer)) so the test remains robust if the default interval changes.
hourAgo := time.Now().Add(-time.Hour)
require.NoError(t, os.Chtimes(testConfigFilename, hourAgo, hourAgo))
The doc comments on the new tests and on the version check were several lines of justification each, longer than anything else in the package. Most tests here have no doc comment at all. Keep one or two lines that say what the test pins down and drop the rest.
Problem
Update()compares the local config'smetadata.versionagainst the one it just fetched. The remote side is guarded on being non-empty. The local side is not:So a local config whose metadata has a
urlbut noversionfails with:That failure is permanent. The error returns before
writeFile, so the unusable version stays on disk and every later run fails the same way. The check runs fromPersistentPreRun, so the message appears on every command and the config never refreshes again. Only a manual reinstall or a hand edit recovers it.How a config gets into that state
The CLI writes such configs itself.
processConfig()addsmetadata.urlanddownloaded_atto whatever it fetches, but it addsversiononly if the served config had one:So any config installed by
config:install, or written by an earlier auto-refresh, from a served config that carried no version, has exactly that shape. When the publisher later starts serving aversion(the point of the field), every install already out there breaks at once.We hit this on a vendor CLI. Its served config gained a
metadata.version, and installs predating that change started printing the error on every command. We have stopped servingmetadata.versionand switched tometadata.updated_at, which skips the comparison and repairs affected installs on their next run.Fix
A local version that will not parse says nothing about whether the fetched config is newer, so treat it as out of date and let the update proceed. The rewritten file carries the new version, so the next run compares cleanly. It logs the parse failure instead of returning it.
Tests
TestUpdateasserted the old behaviour for a local version of"invalid", and now expects the update to be applied.TestUpdateWithUnusableLocalVersionis new and covers local"","invalid"and"1.2.3.4". It also checks that the rewritten file ends up with the new version, so the bad state is not re-entered.Both fail without the change and pass with it.
go test ./internal/config/...is green,go vetandgofmtare clean.go build ./...needsinternal/legacy/archives/php_*, which a fresh checkout does not have, so I scoped the build to the affected packages.