From 404f9d0c65b0a92e44b9ebd90f65f20841fd7b47 Mon Sep 17 00:00:00 2001
From: Vitor Hugo <65777252+vitorhugo-dotnet@users.noreply.github.com>
Date: Mon, 10 Aug 2026 17:39:47 -0300
Subject: [PATCH 01/12] test: define repository id update lookup
---
internal/updatecheck/checker_test.go | 98 ++++++++++++++++++++++++++++
1 file changed, 98 insertions(+)
create mode 100644 internal/updatecheck/checker_test.go
diff --git a/internal/updatecheck/checker_test.go b/internal/updatecheck/checker_test.go
new file mode 100644
index 0000000..da60883
--- /dev/null
+++ b/internal/updatecheck/checker_test.go
@@ -0,0 +1,98 @@
+package updatecheck
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "testing"
+)
+
+func TestCheckerResolvesRepositoryByIDBeforeLatestRelease(t *testing.T) {
+ var requestedPaths []string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requestedPaths = append(requestedPaths, r.URL.Path)
+ w.Header().Set("Content-Type", "application/json")
+
+ switch r.URL.Path {
+ case "/repositories/1326685411":
+ _ = json.NewEncoder(w).Encode(map[string]string{
+ "full_name": "renamed-owner/go-script-sql-runner",
+ })
+ case "/repos/renamed-owner/go-script-sql-runner/releases/latest":
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "tag_name": "build-42",
+ "html_url": "https://github.com/renamed-owner/go-script-sql-runner/releases/tag/build-42",
+ "assets": []map[string]string{
+ {
+ "name": "go-script-sql-runner.exe",
+ "browser_download_url": "https://github.com/renamed-owner/go-script-sql-runner/releases/download/build-42/go-script-sql-runner.exe",
+ },
+ },
+ })
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ checker := &Checker{
+ client: server.Client(),
+ apiBaseURL: server.URL,
+ repositoryID: 1326685411,
+ currentTag: "build-41",
+ }
+
+ result, err := checker.Check(context.Background())
+ if err != nil {
+ t.Fatalf("Check() error = %v", err)
+ }
+
+ wantPaths := []string{
+ "/repositories/1326685411",
+ "/repos/renamed-owner/go-script-sql-runner/releases/latest",
+ }
+ if !reflect.DeepEqual(requestedPaths, wantPaths) {
+ t.Fatalf("requested paths = %v, want %v", requestedPaths, wantPaths)
+ }
+ if !result.Available {
+ t.Fatal("expected update to be available")
+ }
+ if result.LatestTag != "build-42" {
+ t.Fatalf("LatestTag = %q, want build-42", result.LatestTag)
+ }
+ if result.DownloadURL == "" {
+ t.Fatal("expected executable download URL")
+ }
+}
+
+func TestCheckerDoesNotMarkCurrentBuildAsUpdate(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/repositories/1326685411":
+ _, _ = w.Write([]byte(`{"full_name":"owner/go-script-sql-runner"}`))
+ case "/repos/owner/go-script-sql-runner/releases/latest":
+ _, _ = w.Write([]byte(`{"tag_name":"build-42","html_url":"https://example.invalid/build-42","assets":[]}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ checker := &Checker{
+ client: server.Client(),
+ apiBaseURL: server.URL,
+ repositoryID: 1326685411,
+ currentTag: "build-42",
+ }
+
+ result, err := checker.Check(context.Background())
+ if err != nil {
+ t.Fatalf("Check() error = %v", err)
+ }
+ if result.Available {
+ t.Fatal("did not expect current build to be marked as an update")
+ }
+}
From f3eefec735d6a4838f2f9880e10cc103e5a058f7 Mon Sep 17 00:00:00 2001
From: Vitor Hugo <65777252+vitorhugo-dotnet@users.noreply.github.com>
Date: Mon, 10 Aug 2026 17:39:57 -0300
Subject: [PATCH 02/12] test: define update availability notice
---
frontend/src/components/UpdateNotice.test.tsx | 48 +++++++++++++++++++
1 file changed, 48 insertions(+)
create mode 100644 frontend/src/components/UpdateNotice.test.tsx
diff --git a/frontend/src/components/UpdateNotice.test.tsx b/frontend/src/components/UpdateNotice.test.tsx
new file mode 100644
index 0000000..808c1c1
--- /dev/null
+++ b/frontend/src/components/UpdateNotice.test.tsx
@@ -0,0 +1,48 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+import UpdateNotice from './UpdateNotice'
+
+describe('UpdateNotice', () => {
+ it('opens the executable download when an update is available', async () => {
+ const user = userEvent.setup()
+ const onOpen = vi.fn()
+
+ render(
+ ,
+ )
+
+ await user.click(screen.getByRole('button', { name: 'Update build-42' }))
+
+ expect(onOpen).toHaveBeenCalledWith(
+ 'https://github.com/renamed-owner/go-script-sql-runner/releases/download/build-42/go-script-sql-runner.exe',
+ )
+ })
+
+ it('renders nothing when the installed build is current', () => {
+ const { container } = render(
+ ,
+ )
+
+ expect(container).toBeEmptyDOMElement()
+ })
+})
From efb234fd8253a7a320527462b95984ebd464cb9f Mon Sep 17 00:00:00 2001
From: Vitor Hugo <65777252+vitorhugo-dotnet@users.noreply.github.com>
Date: Mon, 10 Aug 2026 17:40:23 -0300
Subject: [PATCH 03/12] feat: resolve update releases by repository id
---
internal/updatecheck/checker.go | 140 ++++++++++++++++++++++++++++++++
1 file changed, 140 insertions(+)
create mode 100644 internal/updatecheck/checker.go
diff --git a/internal/updatecheck/checker.go b/internal/updatecheck/checker.go
new file mode 100644
index 0000000..ed6607c
--- /dev/null
+++ b/internal/updatecheck/checker.go
@@ -0,0 +1,140 @@
+package updatecheck
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ RepositoryID int64 = 1326685411
+ defaultAPIBaseURL = "https://api.github.com"
+ defaultAssetName = "go-script-sql-runner.exe"
+ githubAPIVersion = "2026-03-10"
+)
+
+type Result struct {
+ CurrentTag string `json:"currentTag"`
+ LatestTag string `json:"latestTag"`
+ Available bool `json:"available"`
+ ReleaseURL string `json:"releaseUrl"`
+ DownloadURL string `json:"downloadUrl"`
+}
+
+type Checker struct {
+ client *http.Client
+ apiBaseURL string
+ repositoryID int64
+ currentTag string
+}
+
+type repositoryResponse struct {
+ FullName string `json:"full_name"`
+}
+
+type releaseResponse struct {
+ TagName string `json:"tag_name"`
+ HTMLURL string `json:"html_url"`
+ Assets []struct {
+ Name string `json:"name"`
+ BrowserDownloadURL string `json:"browser_download_url"`
+ } `json:"assets"`
+}
+
+func New(currentTag string) *Checker {
+ return &Checker{
+ client: &http.Client{Timeout: 5 * time.Second},
+ apiBaseURL: defaultAPIBaseURL,
+ repositoryID: RepositoryID,
+ currentTag: strings.TrimSpace(currentTag),
+ }
+}
+
+func (c *Checker) Check(ctx context.Context) (Result, error) {
+ var repository repositoryResponse
+ if err := c.getJSON(ctx, fmt.Sprintf("/repositories/%d", c.repositoryID), &repository); err != nil {
+ return Result{}, fmt.Errorf("resolve update repository: %w", err)
+ }
+
+ parts := strings.Split(strings.TrimSpace(repository.FullName), "/")
+ if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
+ return Result{}, fmt.Errorf("resolve update repository: invalid full_name %q", repository.FullName)
+ }
+
+ releasePath := fmt.Sprintf(
+ "/repos/%s/%s/releases/latest",
+ url.PathEscape(parts[0]),
+ url.PathEscape(parts[1]),
+ )
+ var release releaseResponse
+ if err := c.getJSON(ctx, releasePath, &release); err != nil {
+ return Result{}, fmt.Errorf("get latest release: %w", err)
+ }
+
+ result := Result{
+ CurrentTag: c.currentTag,
+ LatestTag: release.TagName,
+ Available: isNewerBuild(c.currentTag, release.TagName),
+ ReleaseURL: release.HTMLURL,
+ }
+ for _, asset := range release.Assets {
+ if asset.Name == defaultAssetName {
+ result.DownloadURL = asset.BrowserDownloadURL
+ break
+ }
+ }
+
+ return result, nil
+}
+
+func (c *Checker) getJSON(ctx context.Context, path string, target any) error {
+ if c.client == nil {
+ return fmt.Errorf("HTTP client is not configured")
+ }
+
+ requestURL := strings.TrimRight(c.apiBaseURL, "/") + path
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Accept", "application/vnd.github+json")
+ req.Header.Set("X-GitHub-Api-Version", githubAPIVersion)
+ req.Header.Set("User-Agent", "go-script-sql-runner")
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
+ return fmt.Errorf("GitHub API returned %s", resp.Status)
+ }
+ if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
+ return fmt.Errorf("decode GitHub API response: %w", err)
+ }
+ return nil
+}
+
+func isNewerBuild(currentTag, latestTag string) bool {
+ currentBuild, currentOK := parseBuildNumber(currentTag)
+ latestBuild, latestOK := parseBuildNumber(latestTag)
+ return currentOK && latestOK && latestBuild > currentBuild
+}
+
+func parseBuildNumber(tag string) (int, bool) {
+ value := strings.TrimPrefix(strings.TrimSpace(tag), "build-")
+ if value == tag || value == "" {
+ return 0, false
+ }
+ build, err := strconv.Atoi(value)
+ if err != nil || build < 0 {
+ return 0, false
+ }
+ return build, true
+}
From 7300b3a55ad2d714790deccd53a284713e25e7be Mon Sep 17 00:00:00 2001
From: Vitor Hugo <65777252+vitorhugo-dotnet@users.noreply.github.com>
Date: Mon, 10 Aug 2026 17:40:31 -0300
Subject: [PATCH 04/12] feat: add update availability notice
---
frontend/src/components/UpdateNotice.tsx | 23 +++++++++++++++++++++++
1 file changed, 23 insertions(+)
create mode 100644 frontend/src/components/UpdateNotice.tsx
diff --git a/frontend/src/components/UpdateNotice.tsx b/frontend/src/components/UpdateNotice.tsx
new file mode 100644
index 0000000..e08baee
--- /dev/null
+++ b/frontend/src/components/UpdateNotice.tsx
@@ -0,0 +1,23 @@
+import type { UpdateInfo } from '../api/types'
+
+interface UpdateNoticeProps {
+ info: UpdateInfo | null
+ onOpen(url: string): void
+}
+
+export default function UpdateNotice({ info, onOpen }: UpdateNoticeProps) {
+ if (!info?.available) return null
+
+ const targetUrl = info.downloadUrl || info.releaseUrl
+ if (!targetUrl) return null
+
+ return (
+
+ )
+}
From 45f31c3c29fe608b53c4ec7f66d1c68d75469775 Mon Sep 17 00:00:00 2001
From: Vitor Hugo <65777252+vitorhugo-dotnet@users.noreply.github.com>
Date: Mon, 10 Aug 2026 17:40:43 -0300
Subject: [PATCH 05/12] feat: expose update checker frontend types
---
frontend/src/api/types.ts | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index 5fa598e..8511b60 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -77,6 +77,14 @@ export interface RunOptions {
transactionMode?: TransactionMode | ''
}
+export interface UpdateInfo {
+ currentTag: string
+ latestTag: string
+ available: boolean
+ releaseUrl: string
+ downloadUrl: string
+}
+
export interface RunnerApi {
listProfiles(): Promise
getProfile(profileID: string): Promise
@@ -93,5 +101,7 @@ export interface RunnerApi {
stopRun(): Promise
importProfileFromDialog(): Promise
exportProfileToDialog(profileID: string): Promise
+ checkForUpdates(): Promise
+ openExternalURL(url: string): void
onExecutionEvent(handler: (event: ExecutionEvent) => void): () => void
}
From d2dd010fee013a99b04fdaec6b5a18b2d752f7a0 Mon Sep 17 00:00:00 2001
From: Vitor Hugo <65777252+vitorhugo-dotnet@users.noreply.github.com>
Date: Mon, 10 Aug 2026 17:40:58 -0300
Subject: [PATCH 06/12] feat: bridge update checks to desktop UI
---
frontend/src/api/runner.ts | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/frontend/src/api/runner.ts b/frontend/src/api/runner.ts
index 365e7cf..a7e7a82 100644
--- a/frontend/src/api/runner.ts
+++ b/frontend/src/api/runner.ts
@@ -7,6 +7,7 @@ import type {
RunnerApi,
Script,
TransactionMode,
+ UpdateInfo,
} from './types'
interface DesktopBinding {
@@ -25,10 +26,12 @@ interface DesktopBinding {
StopRun(): Promise
ImportProfileFromDialog(): Promise
ExportProfileToDialog(profileID: string): Promise
+ CheckForUpdates(): Promise
}
interface WailsRuntime {
EventsOn(eventName: string, callback: (payload?: unknown) => void): () => void
+ BrowserOpenURL(url: string): void
}
declare global {
@@ -67,6 +70,14 @@ export const wailsRunnerApi: RunnerApi = {
stopRun: () => desktop().StopRun(),
importProfileFromDialog: () => desktop().ImportProfileFromDialog(),
exportProfileToDialog: (profileID) => desktop().ExportProfileToDialog(profileID),
+ checkForUpdates: () => desktop().CheckForUpdates(),
+ openExternalURL: (url) => {
+ if (window.runtime?.BrowserOpenURL) {
+ window.runtime.BrowserOpenURL(url)
+ return
+ }
+ window.open(url, '_blank', 'noopener,noreferrer')
+ },
onExecutionEvent: (handler) => {
if (!window.runtime?.EventsOn) {
return () => undefined
From 2dfcf04332d7b4e563edddc614046329deb71288 Mon Sep 17 00:00:00 2001
From: Vitor Hugo <65777252+vitorhugo-dotnet@users.noreply.github.com>
Date: Mon, 10 Aug 2026 17:41:19 -0300
Subject: [PATCH 07/12] feat: expose repository id update check to Wails
---
internal/ui/wails/desktop_app.go | 33 +++++++++++++++++++++++++++++---
1 file changed, 30 insertions(+), 3 deletions(-)
diff --git a/internal/ui/wails/desktop_app.go b/internal/ui/wails/desktop_app.go
index 3cb0965..7886452 100644
--- a/internal/ui/wails/desktop_app.go
+++ b/internal/ui/wails/desktop_app.go
@@ -9,17 +9,22 @@ import (
"github.com/vitorhugo-dotnet/go-script-sql-runner/internal/executor"
"github.com/vitorhugo-dotnet/go-script-sql-runner/internal/profile"
"github.com/vitorhugo-dotnet/go-script-sql-runner/internal/ui"
+ "github.com/vitorhugo-dotnet/go-script-sql-runner/internal/updatecheck"
)
type DesktopApp struct {
bridge *ui.Bridge
- mu sync.RWMutex
- ctx context.Context
+ mu sync.RWMutex
+ ctx context.Context
+ currentBuildTag string
}
func NewDesktopApp(bridge *ui.Bridge) *DesktopApp {
- return &DesktopApp{bridge: bridge}
+ return &DesktopApp{
+ bridge: bridge,
+ currentBuildTag: "dev",
+ }
}
func (a *DesktopApp) Startup(ctx context.Context) {
@@ -28,6 +33,15 @@ func (a *DesktopApp) Startup(ctx context.Context) {
a.mu.Unlock()
}
+func (a *DesktopApp) SetCurrentBuildTag(tag string) {
+ if tag == "" {
+ tag = "dev"
+ }
+ a.mu.Lock()
+ a.currentBuildTag = tag
+ a.mu.Unlock()
+}
+
func (a *DesktopApp) appContext() (context.Context, error) {
a.mu.RLock()
defer a.mu.RUnlock()
@@ -124,3 +138,16 @@ func (a *DesktopApp) ExportProfileToDialog(profileID string) (string, error) {
if err != nil { return "", err }
return a.bridge.ExportProfileToDialog(ctx, profileID)
}
+
+func (a *DesktopApp) CheckForUpdates() (updatecheck.Result, error) {
+ ctx, err := a.appContext()
+ if err != nil {
+ return updatecheck.Result{}, err
+ }
+
+ a.mu.RLock()
+ currentBuildTag := a.currentBuildTag
+ a.mu.RUnlock()
+
+ return updatecheck.New(currentBuildTag).Check(ctx)
+}
From 3e9408f8779bf0ec37f3f53e3e0b05ccd1b11d20 Mon Sep 17 00:00:00 2001
From: Vitor Hugo <65777252+vitorhugo-dotnet@users.noreply.github.com>
Date: Mon, 10 Aug 2026 17:41:31 -0300
Subject: [PATCH 08/12] feat: pass embedded build tag to desktop updater
---
main.go | 3 +++
1 file changed, 3 insertions(+)
diff --git a/main.go b/main.go
index 72958c0..4ebca85 100644
--- a/main.go
+++ b/main.go
@@ -18,6 +18,8 @@ import (
//go:embed all:frontend/dist
var assets embed.FS
+var currentBuildTag = "dev"
+
func main() {
os.Exit(run())
}
@@ -43,6 +45,7 @@ func run() int {
events := wailsui.EventAdapter{}
bridge := ui.NewBridge(runtime.Service, dialogs, events)
desktop := wailsui.NewDesktopApp(bridge)
+ desktop.SetCurrentBuildTag(currentBuildTag)
err = wails.Run(&options.App{
Title: "Go Script SQL Runner",
From 3de69893a35b417d770faac623c13931b17d944a Mon Sep 17 00:00:00 2001
From: Vitor Hugo <65777252+vitorhugo-dotnet@users.noreply.github.com>
Date: Mon, 10 Aug 2026 17:42:03 -0300
Subject: [PATCH 09/12] feat: check releases when the desktop app opens
---
frontend/src/App.tsx | 24 ++++++++++++++++++++++--
1 file changed, 22 insertions(+), 2 deletions(-)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index a7abad7..fef540f 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,8 +1,9 @@
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
import { wailsRunnerApi } from './api/runner'
-import type { OnError, RunnerApi, TransactionMode } from './api/types'
+import type { OnError, RunnerApi, TransactionMode, UpdateInfo } from './api/types'
import ProfileDialog from './components/ProfileDialog'
import SchemaSelect from './components/SchemaSelect'
+import UpdateNotice from './components/UpdateNotice'
import { useRunnerController } from './state/useRunnerController'
const buttonClass =
@@ -30,12 +31,30 @@ export default function App({ api = wailsRunnerApi }: AppProps) {
const controller = useRunnerController(api)
const [detailedLogs, setDetailedLogs] = useState(false)
const [profileDialogMode, setProfileDialogMode] = useState<'create' | 'edit' | null>(null)
+ const [updateInfo, setUpdateInfo] = useState(null)
const profile = controller.selectedProfile
const scripts = [...(profile?.scripts ?? [])].sort((left, right) => left.order - right.order)
const connectionSummary = profile
? `${profile.connection.host}:${profile.connection.port}`
: 'No connection configured'
+ useEffect(() => {
+ let cancelled = false
+
+ void (async () => {
+ try {
+ const info = await api.checkForUpdates()
+ if (!cancelled) setUpdateInfo(info)
+ } catch {
+ // Update checks are best-effort and must never block the runner UI.
+ }
+ })()
+
+ return () => {
+ cancelled = true
+ }
+ }, [api])
+
return (
<>
@@ -74,6 +93,7 @@ export default function App({ api = wailsRunnerApi }: AppProps) {
Edit
+