Skip to content

fix(config): update config when the local version is unusable - #155

Open
ziomizar wants to merge 4 commits into
mainfrom
fix-config-update-empty-local-version
Open

fix(config): update config when the local version is unusable#155
ziomizar wants to merge 4 commits into
mainfrom
fix-config-update-empty-local-version

Conversation

@ziomizar

Copy link
Copy Markdown

Problem

Update() compares the local config's metadata.version against the one it just fetched. The remote side is guarded on being non-empty. The local side is not:

if newCnfStruct.Metadata.Version != "" {
    cmp, err := version.Compare(cnf.Metadata.Version, newCnfStruct.Metadata.Version)
    if err != nil {
        return fmt.Errorf("could not compare config versions: %w", err)
    }

So a local config whose metadata has a url but no version fails with:

Error updating config: could not compare config versions: invalid semantic version

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 from PersistentPreRun, 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() adds metadata.url and downloaded_at to whatever it fetches, but it adds version only if the served config had one:

metadata := &cnf.Metadata
if metadata.URL != "" { ... } else { metadata.URL = downloadURL }
metadata.DownloadedAt = downloadedAt

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 a version (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 serving metadata.version and switched to metadata.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.

if newCnfStruct.Metadata.Version != "" && cnf.Metadata.Version != "" {
    cmp, err := version.Compare(cnf.Metadata.Version, newCnfStruct.Metadata.Version)
    if err != nil {
        debugLog("Could not compare config versions (local %q, new %q): %s", ...)
    } else if cmp >= 0 {
        debugLog("Config is already up to date (version %s)", cnf.Metadata.Version)
        return nil
    }
}

Tests

TestUpdate asserted the old behaviour for a local version of "invalid", and now expects the update to be applied. TestUpdateWithUnusableLocalVersion is 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 vet and gofmt are clean. go build ./... needs internal/legacy/archives/php_*, which a fresh checkout does not have, so I scoped the build to the affected packages.

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.
Copilot AI lite review requested due to automatic review settings August 19, 2026 10:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Update to 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 UpdatedAt is 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 newer UpdatedAt) 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.

Comment thread internal/config/alt/update.go Outdated
Comment thread internal/config/alt/update_test.go Outdated
Comment thread internal/config/alt/update.go Outdated
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.
@ziomizar
ziomizar requested a lite review from Copilot August 19, 2026 10:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 if alt.Update adds 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.
@ziomizar
ziomizar requested a lite review from Copilot August 19, 2026 12:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 from testConfig). If testConfig has spare capacity, append can mutate its backing array and leak changes across tests. Prefer using the new testConfigWithMetadata(...) 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 - 1h to bypass Update’s CheckInterval short-circuit. If the configured cnf.Updates.CheckInterval is 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants