Skip to content

Conversation

@bendrucker
Copy link
Member

@bendrucker bendrucker commented Nov 26, 2025

Implements version update notifications similar to Terraform. When running tflint --version, users now see a notification if a newer version is available on GitHub Releases.

Changes

  • Adds versioncheck package that checks GitHub Releases API for latest version
  • Modifies --version command to display update notifications when newer version available
  • Adds --format json support to version command with structured output including update status
  • Supports TFLINT_DISABLE_VERSION_CHECK environment variable to opt out
  • Caches version check results for 48 hours in platform-specific cache directory

Implementation Details

Version checking happens asynchronously for text output (prints version immediately, shows update after plugins). JSON format performs synchronous check to ensure complete data in single output.

Results are cached in the OS cache directory (~/Library/Caches/tflint on macOS, ~/.cache/tflint on Linux) for 48 hours to minimize API calls.

The check uses GitHub's public API (60 req/hour unauthenticated) or GITHUB_TOKEN environment variable for higher rate limits (5000 req/hour).

Errors are logged but non-fatal - network timeouts or API failures don't break the version command.

JSON Output

JSON output includes update_check_enabled field to distinguish between disabled checking vs. no update available:

  • update_check_enabled: false → checking disabled via env var
  • update_check_enabled: true, update_available: false → no update or check failed
  • update_check_enabled: true, update_available: true → update available

Testing

Verified version checking against live GitHub API with both outdated and current versions. Added unit testing against the internals.

References

Closes #883

Implements version update notifications similar to Terraform's pattern.
When running tflint --version, users see a notification if a newer version
is available on GitHub Releases.

## Changes

- Adds `versioncheck` package that checks GitHub Releases API for latest version
- Modifies `--version` command to display update notifications when newer version available
- Adds `--format json` support to version command with structured output including update status
- Supports `TFLINT_DISABLE_VERSION_CHECK` environment variable to opt out
- Caches version check results for 48 hours in platform-specific cache directory

## Implementation Details

Version checking happens on every `--version` invocation with a 3-second timeout.
Results are cached in the OS cache directory (`~/Library/Caches/tflint` on macOS,
`~/.cache/tflint` on Linux) for 48 hours to minimize API calls.

The check uses GitHub's public API (60 req/hour unauthenticated) or `GITHUB_TOKEN`
environment variable for higher rate limits (5000 req/hour).

Errors are logged but non-fatal - network timeouts or API failures don't break
the version command.

## Testing

Verified version checking against live GitHub API with both outdated and current versions.
Cache behavior tested with 48-hour TTL validation. JSON output structure validated
for both text and JSON formats.

Closes #883
@bendrucker bendrucker changed the title feat: add version update notifications version: add update notifications Nov 26, 2025
@bendrucker bendrucker changed the title version: add update notifications version: add update notifications, json output Nov 26, 2025
@bendrucker bendrucker requested a review from Copilot November 26, 2025 21:45
Copilot finished reviewing on behalf of bendrucker November 26, 2025 21:49
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR implements version update notifications for TFLint, similar to Terraform's approach. When running tflint --version, users are notified if a newer version is available on GitHub Releases. The implementation includes JSON output support via --format json and can be disabled using the TFLINT_DISABLE_VERSION_CHECK environment variable.

Key changes:

  • Adds version checking with GitHub Releases API integration (60 req/hour unauthenticated, 5000 req/hour with GITHUB_TOKEN)
  • Implements 48-hour caching in platform-specific cache directories to minimize API calls
  • Adds JSON output format for version command with structured update information

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
versioncheck/github.go Implements GitHub API integration to fetch latest release version with optional authentication and rate limit handling
versioncheck/check.go Core version checking logic with caching support and version comparison
versioncheck/check_test.go Unit tests for Enabled() and compareVersions() functions
versioncheck/cache.go Cache management with 48-hour TTL using platform-specific cache directories
versioncheck/cache_test.go Unit tests for cache expiration logic
cmd/version.go Integrates version checking into --version command with text and JSON output formats
docs/user-guide/environment_variables.md Documents TFLINT_DISABLE_VERSION_CHECK environment variable

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 20 to 48
func fetchLatestRelease(ctx context.Context) (string, error) {
// Create GitHub client with optional authentication
hc := &http.Client{Transport: http.DefaultTransport}
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
hc = oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{
AccessToken: token,
}))
}
client := github.NewClient(hc)

log.Printf("[DEBUG] Fetching latest release from GitHub API")
release, resp, err := client.Repositories.GetLatestRelease(ctx, repoOwner, repoName)
if err != nil {
// Check if it's a rate limit error
if resp != nil && resp.StatusCode == http.StatusForbidden {
if resp.Rate.Remaining == 0 {
log.Printf("[ERROR] GitHub API rate limited, consider setting GITHUB_TOKEN")
}
}
return "", fmt.Errorf("failed to fetch latest release: %w", err)
}

if release.TagName == nil {
return "", fmt.Errorf("latest release has no tag name")
}

log.Printf("[DEBUG] Latest release: %s", *release.TagName)
return *release.TagName, nil
}
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

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

The fetchLatestRelease function lacks test coverage. Consider adding tests using a mock GitHub client or HTTP test server to verify behavior for:

  • Successful API responses
  • Rate limit scenarios
  • Network timeouts (via context)
  • Missing tag names in releases
    This would improve reliability and make debugging easier.

Copilot uses AI. Check for mistakes.
Comment on lines 35 to 38
// CheckForUpdate checks if a new version of tflint is available
// It returns UpdateInfo indicating if an update is available and the latest version string
// Errors are logged but not returned - failures should not break the version command
func CheckForUpdate(ctx context.Context, current *version.Version) (*UpdateInfo, error) {
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

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

The function comment on line 37 states "Errors are logged but not returned - failures should not break the version command", but the function does return errors (line 53). This inconsistency between documentation and implementation is misleading. Either update the comment to reflect that errors are returned, or change the implementation to match the documented behavior of only logging errors.

Copilot uses AI. Check for mistakes.
Comment on lines 59 to 95
func TestCompareVersions(t *testing.T) {
tests := []struct {
name string
current string
latest string
wantAvailable bool
wantError bool
}{
{
name: "update available",
current: "0.59.0",
latest: "0.60.0",
wantAvailable: true,
wantError: false,
},
{
name: "v prefix stripped",
current: "0.59.0",
latest: "v0.60.0",
wantAvailable: true,
wantError: false,
},
{
name: "already latest",
current: "0.60.0",
latest: "0.60.0",
wantAvailable: false,
wantError: false,
},
{
name: "invalid latest version",
current: "0.60.0",
latest: "invalid",
wantAvailable: false,
wantError: true,
},
}
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

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

The test suite lacks a test case for when the current version is newer than the latest release (e.g., running a development build like version 0.61.0 when latest is 0.60.0). Consider adding a test case like:

{
    name:          "current newer than latest",
    current:       "0.61.0",
    latest:        "0.60.0",
    wantAvailable: false,
    wantError:     false,
}

This ensures the version comparison works correctly for all scenarios.

Copilot uses AI. Check for mistakes.
Comment on lines 10 to 31
func TestEnabled(t *testing.T) {
tests := []struct {
name string
envValue string
want bool
}{
{
name: "not set - enabled by default",
envValue: "",
want: true,
},
{
name: "disabled",
envValue: "1",
want: false,
},
{
name: "invalid value - enabled by default",
envValue: "invalid",
want: true,
},
}
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

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

The test coverage for Enabled() could be more comprehensive. Consider adding test cases for other valid strconv.ParseBool values:

  • "0" (should enable, as it means "false" for disable)
  • "true"/"True"/"TRUE" (should disable)
  • "false"/"False"/"FALSE" (should enable)

This would ensure the function handles all valid boolean string representations correctly.

Copilot uses AI. Check for mistakes.
}

expectedLatest := tt.latest
if expectedLatest[0] == 'v' {
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

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

Accessing expectedLatest[0] without checking if the string is empty could cause a panic if an empty string test case is added. Consider adding a length check:

if len(expectedLatest) > 0 && expectedLatest[0] == 'v' {
    expectedLatest = expectedLatest[1:]
}

This makes the test more robust against future modifications.

Suggested change
if expectedLatest[0] == 'v' {
if len(expectedLatest) > 0 && expectedLatest[0] == 'v' {

Copilot uses AI. Check for mistakes.
- `TFLINT_PLUGIN_DIR`
- Configure the plugin directory. See [Configuring Plugins](./plugins.md).
- `TFLINT_DISABLE_VERSION_CHECK`
- Disable version update notifications when running `tflint --version`. Set to `1` to disable.
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

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

The GITHUB_TOKEN environment variable is used for authenticated GitHub API requests (as mentioned in lines 23-27 of versioncheck/github.go) but is not documented in the environment variables documentation. Consider adding documentation for this optional variable explaining:

  • Its purpose (to increase GitHub API rate limits from 60 to 5000 req/hour)
  • When it's needed
  • How to obtain a token
Suggested change
- Disable version update notifications when running `tflint --version`. Set to `1` to disable.
- Disable version update notifications when running `tflint --version`. Set to `1` to disable.
- `GITHUB_TOKEN`
- (Optional) Used for authenticated GitHub API requests to increase the rate limit from 60 to 5000 requests per hour. This is needed if you encounter rate limit errors when running commands that check for updates or interact with GitHub. You can obtain a token by creating a [GitHub personal access token](https://github.com/settings/tokens); no special scopes are required.

Copilot uses AI. Check for mistakes.
Comment on lines 101 to 113
func (cli *CLI) printVersionJSON(opts Options, updateInfo *versioncheck.UpdateInfo) int {
// Build output
output := VersionOutput{
Version: tflint.Version.String(),
Plugins: getPluginVersions(opts),
}

if updateInfo != nil {
output.UpdateAvailable = updateInfo.Available
if updateInfo.Available {
output.LatestVersion = updateInfo.Latest
}
}
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

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

The JSON output structure may be ambiguous when version checking fails or is disabled. The update_available field will be false in three different scenarios:

  1. Version check succeeded and no update is available
  2. Version check was disabled via TFLINT_DISABLE_VERSION_CHECK
  3. Version check failed (network error, API error, etc.)

Consider adding an additional field to distinguish these cases, such as:

type VersionOutput struct {
    Version            string          `json:"version"`
    Plugins            []PluginVersion `json:"plugins"`
    UpdateCheckEnabled bool            `json:"update_check_enabled"`
    UpdateAvailable    bool            `json:"update_available"`
    LatestVersion      string          `json:"latest_version,omitempty"`
}

This would make the JSON output clearer for programmatic consumers.

Copilot uses AI. Check for mistakes.
- Fix rate limit check in GitHub API error handling
- Remove misleading comment about error handling
- Add boundary checks in tests to prevent panics
- Expand test coverage for version comparison and environment variables
- Document GITHUB_TOKEN environment variable
- Make GitHub client testable with httptest
Addresses errcheck linter violation
@bendrucker bendrucker requested a review from Copilot November 27, 2025 17:38
Copilot finished reviewing on behalf of bendrucker November 27, 2025 17:43
Copy link

Copilot AI left a comment

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 8 out of 8 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

cmd/version.go Outdated
Comment on lines 37 to 64
// Check for updates (unless disabled)
var updateInfo *versioncheck.UpdateInfo
if versioncheck.Enabled() {
ctx, cancel := context.WithTimeout(context.Background(), versionCheckTimeout)
defer cancel()

info, err := versioncheck.CheckForUpdate(ctx, tflint.Version)
if err != nil {
log.Printf("[ERROR] Failed to check for updates: %s", err)
} else {
updateInfo = info
}
}

// If JSON format requested, output JSON
if opts.Format == "json" {
return cli.printVersionJSON(opts, updateInfo)
}

// Print version
fmt.Fprintf(cli.outStream, "TFLint version %s\n", tflint.Version)

// Print update notification if available
if updateInfo != nil && updateInfo.Available {
fmt.Fprintf(cli.outStream, "\n")
fmt.Fprintf(cli.outStream, "Your version of TFLint is out of date! The latest version\n")
fmt.Fprintf(cli.outStream, "is %s. You can update by downloading from https://github.com/terraform-linters/tflint/releases\n", updateInfo.Latest)
}
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

[nitpick] The version check is performed synchronously before printing the version (lines 37-49), which delays the version output by up to 3 seconds even when the cache is valid. Consider performing the version check and output in parallel: print the version immediately, then show the update notification after if needed. This would improve perceived responsiveness for users.

Copilot uses AI. Check for mistakes.
Comment on lines 79 to 82
client := &http.Client{Transport: http.DefaultTransport}
ghClient := github.NewClient(client)
serverURL, _ := url.Parse(server.URL + "/")
ghClient.BaseURL = serverURL
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

The ghClient variable is created but never used in the test. These lines (79-82) create a GitHub client and set its base URL, but then a separate testClient is created on lines 85-89. Consider removing these unused lines to clean up the test code.

Suggested change
client := &http.Client{Transport: http.DefaultTransport}
ghClient := github.NewClient(client)
serverURL, _ := url.Parse(server.URL + "/")
ghClient.BaseURL = serverURL

Copilot uses AI. Check for mistakes.
Comment on lines +13 to +14
- `GITHUB_TOKEN`
- (Optional) Used for authenticated GitHub API requests when checking for updates and downloading plugins. Increases the rate limit from 60 to 5000 requests per hour. Useful if you encounter rate limit errors. You can obtain a token by creating a [GitHub personal access token](https://github.com/settings/tokens); no special scopes are required.
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

[nitpick] The GITHUB_TOKEN documentation mentions it's used "when checking for updates and downloading plugins" but this placement under version-related variables might be confusing. Consider moving this documentation to a more general location since it already exists elsewhere in the docs and is used for multiple purposes, not just version checking. Also note that the existing plugin code already documents GITHUB_TOKEN usage in the install.go comments (lines 336-344).

Copilot uses AI. Check for mistakes.
cmd/version.go Outdated
Comment on lines 14 to 64
func (cli *CLI) printVersion(opts Options) int {
// Check for updates (unless disabled)
var updateInfo *versioncheck.UpdateInfo
if versioncheck.Enabled() {
ctx, cancel := context.WithTimeout(context.Background(), versionCheckTimeout)
defer cancel()

info, err := versioncheck.CheckForUpdate(ctx, tflint.Version)
if err != nil {
log.Printf("[ERROR] Failed to check for updates: %s", err)
} else {
updateInfo = info
}
}

// If JSON format requested, output JSON
if opts.Format == "json" {
return cli.printVersionJSON(opts, updateInfo)
}

// Print version
fmt.Fprintf(cli.outStream, "TFLint version %s\n", tflint.Version)

// Print update notification if available
if updateInfo != nil && updateInfo.Available {
fmt.Fprintf(cli.outStream, "\n")
fmt.Fprintf(cli.outStream, "Your version of TFLint is out of date! The latest version\n")
fmt.Fprintf(cli.outStream, "is %s. You can update by downloading from https://github.com/terraform-linters/tflint/releases\n", updateInfo.Latest)
}
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

[nitpick] Consider adding integration tests for the new version check functionality and JSON output format. The existing integration tests in integrationtest/cli/cli_test.go test basic version output, but don't cover the new features like update notifications or --format json with the version command. This would help ensure the feature works end-to-end in real-world scenarios.

Copilot uses AI. Check for mistakes.
cmd/version.go Outdated
Comment on lines 61 to 63
fmt.Fprintf(cli.outStream, "\n")
fmt.Fprintf(cli.outStream, "Your version of TFLint is out of date! The latest version\n")
fmt.Fprintf(cli.outStream, "is %s. You can update by downloading from https://github.com/terraform-linters/tflint/releases\n", updateInfo.Latest)
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

[nitpick] The update notification message uses fmt.Fprintf with multiple separate calls, which could lead to interleaved output in concurrent scenarios. Consider consolidating into a single fmt.Fprintf call or using a formatted string. For example:

fmt.Fprintf(cli.outStream, "\nYour version of TFLint is out of date! The latest version\nis %s. You can update by downloading from https://github.com/terraform-linters/tflint/releases\n", updateInfo.Latest)
Suggested change
fmt.Fprintf(cli.outStream, "\n")
fmt.Fprintf(cli.outStream, "Your version of TFLint is out of date! The latest version\n")
fmt.Fprintf(cli.outStream, "is %s. You can update by downloading from https://github.com/terraform-linters/tflint/releases\n", updateInfo.Latest)
fmt.Fprintf(cli.outStream, "\nYour version of TFLint is out of date! The latest version\nis %s. You can update by downloading from https://github.com/terraform-linters/tflint/releases\n", updateInfo.Latest)

Copilot uses AI. Check for mistakes.
Comment on lines +71 to +74
if err := os.WriteFile(cachePath, data, 0644); err != nil {
return err
}

Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

[nitpick] The cache file operations in loadCache and saveCache are not protected against concurrent access. If multiple tflint processes run --version simultaneously, they could race on reading/writing the cache file. Consider using file locking (e.g., syscall.Flock on Unix or similar) or atomic file operations (write to temp file, then rename) to prevent potential corruption. However, since cache corruption would only result in an extra API call (which is not critical), this may be acceptable as-is.

Suggested change
if err := os.WriteFile(cachePath, data, 0644); err != nil {
return err
}
// Write to a temp file, then atomically rename
tmpFile, err := os.CreateTemp(cacheDir, "version_check_cache_*.tmp")
if err != nil {
return err
}
defer func() {
tmpFile.Close()
os.Remove(tmpFile.Name()) // Clean up temp file if rename fails
}()
if _, err := tmpFile.Write(data); err != nil {
return err
}
if err := tmpFile.Sync(); err != nil {
return err
}
if err := tmpFile.Close(); err != nil {
return err
}
if err := os.Rename(tmpFile.Name(), cachePath); err != nil {
return err
}

Copilot uses AI. Check for mistakes.
- Add update_check_enabled field to JSON output for clarity
- Remove unused test variables in github_test.go
- Consolidate notification message for thread safety
- Make version check async for text format to improve responsiveness
@bendrucker bendrucker marked this pull request as ready for review November 29, 2025 03:32
@bendrucker bendrucker requested a review from wata727 November 29, 2025 03:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Inform users when a new version is available

2 participants