Skip to content

Commit 128e739

Browse files
committed
feat: implement update checker and release automation setup
Adds background checking for new versions via GitHub Releases API, with caching to limit API calls. Includes version embedding at build time. Adds GoReleaser configuration and GitHub Actions workflow for automated release builds and publishing triggered by Git tags. Refactors env file loading into internal/util.
1 parent c7e57f1 commit 128e739

File tree

10 files changed

+467
-52
lines changed

10 files changed

+467
-52
lines changed

.github/workflows/release.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Release Reflow
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
goreleaser:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- name: Checkout
16+
uses: actions/checkout@v4
17+
with:
18+
fetch-depth: 0
19+
20+
- name: Set up Go
21+
uses: actions/setup-go@v5
22+
with:
23+
go-version: '1.24.2'
24+
25+
- name: Run GoReleaser
26+
uses: goreleaser/goreleaser-action@v6
27+
with:
28+
version: latest
29+
args: release --clean
30+
env:
31+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.goreleaser.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
project_name: reflow
2+
3+
before:
4+
hooks:
5+
- go mod tidy
6+
7+
builds:
8+
- id: reflow-cli
9+
main: ./main.go
10+
binary: reflow
11+
env:
12+
- CGO_ENABLED=0
13+
ldflags:
14+
- -s -w
15+
- -X reflow/cmd/version.version={{.Version}}
16+
- -X reflow/cmd/version.repository=RevereInc/reflow
17+
goos:
18+
- linux
19+
goarch:
20+
- amd64
21+
- arm64
22+
23+
archives:
24+
- id: reflow-archives
25+
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
26+
format: tar.gz
27+
files:
28+
- README.md
29+
30+
checksum:
31+
name_template: "checksums.txt"
32+
33+
release:
34+
name_template: "Release {{.Version}}"

cmd/root.go

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import (
77
"path/filepath"
88
"reflow/cmd/deploy"
99
"reflow/cmd/destroy"
10+
"reflow/cmd/version"
11+
"reflow/internal/update"
12+
"sync"
13+
"time"
1014

1115
"github.com/spf13/cobra"
1216
"github.com/spf13/viper"
@@ -15,8 +19,10 @@ import (
1519
)
1620

1721
var (
18-
debug bool
19-
cfgFileBase string
22+
debug bool
23+
cfgFileBase string
24+
updateCheckStarted bool
25+
updateCheckMutex sync.Mutex
2026
)
2127

2228
// rootCmd represents the base command when called without any subcommands
@@ -62,6 +68,54 @@ implementing a blue-green deployment strategy to minimize downtime.`,
6268
util.Log.Debug("Debug mode enabled by flag, overriding config file setting if it was false.")
6369
}
6470
}
71+
72+
// --- Perform Update Check (in background) ---
73+
if cmd.Name() != "version" {
74+
updateCheckMutex.Lock()
75+
shouldStartCheck := !updateCheckStarted
76+
if shouldStartCheck {
77+
updateCheckStarted = true
78+
}
79+
updateCheckMutex.Unlock()
80+
81+
if shouldStartCheck {
82+
go func() {
83+
repo := version.GetRepository()
84+
currentVersion := version.GetVersion()
85+
86+
if repo == "" || currentVersion == "dev" {
87+
util.Log.Debug("Skipping update check (repo not set or dev version).")
88+
return
89+
}
90+
91+
cacheDir := filepath.Join(cfgFileBase, ".reflow-state")
92+
cachePath := filepath.Join(cacheDir, update.CacheFileName)
93+
94+
util.Log.Debugf("Initiating background update check for repo: %s", repo)
95+
result, checkErr := update.CheckForUpdate(currentVersion, repo, cachePath, 24*time.Hour)
96+
97+
if checkErr != nil {
98+
util.Log.Debugf("Update check failed: %v", checkErr)
99+
return
100+
}
101+
102+
if result != nil && result.IsNewer {
103+
fmt.Fprintf(os.Stderr, "\n---\n")
104+
fmt.Fprintf(os.Stderr, "[Reflow Update Available]\n")
105+
fmt.Fprintf(os.Stderr, " Your version: %s\n", currentVersion)
106+
fmt.Fprintf(os.Stderr, " Latest version:%s\n", result.LatestVersion)
107+
fmt.Fprintf(os.Stderr, " Release notes: %s\n", result.ReleaseURL)
108+
fmt.Fprintf(os.Stderr, " Consider updating by visiting the release page or rebuilding from source.\n")
109+
fmt.Fprintf(os.Stderr, "---\n\n")
110+
} else if result != nil {
111+
util.Log.Debugf("Update check completed. Version '%s' is up-to-date.", currentVersion)
112+
}
113+
}()
114+
} else {
115+
util.Log.Debug("Update check already initiated for this execution.")
116+
}
117+
}
118+
65119
return nil
66120
},
67121
}
@@ -81,6 +135,7 @@ func init() {
81135
deploy.AddDeployCommand(rootCmd)
82136
deploy.AddApproveCommand(rootCmd)
83137
destroy.AddDestroyCommand(rootCmd)
138+
version.AddVersionCommand(rootCmd)
84139
}
85140

86141
// GetReflowBasePath allows other commands (like init) to access the calculated base path

cmd/version/version.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package version
2+
3+
import (
4+
"fmt"
5+
"os"
6+
rtdebug "runtime/debug"
7+
"strings"
8+
9+
"github.com/spf13/cobra"
10+
)
11+
12+
// set at build time via ldflags.
13+
var version = "dev"
14+
var repository = ""
15+
16+
var buildInfo *rtdebug.BuildInfo
17+
18+
func init() {
19+
var ok bool
20+
buildInfo, ok = rtdebug.ReadBuildInfo()
21+
if !ok {
22+
fmt.Fprintln(os.Stderr, "Warning: Could not read build info via runtime/debug.")
23+
}
24+
}
25+
26+
// AddVersionCommand defines the version command.
27+
func AddVersionCommand(rootCmd *cobra.Command) {
28+
var versionCmd = &cobra.Command{
29+
Use: "version",
30+
Short: "Print the version number and repository of Reflow",
31+
Long: `Displays the current version and source repository of the Reflow executable.`,
32+
Args: cobra.NoArgs,
33+
Run: func(cmd *cobra.Command, args []string) {
34+
fmt.Printf("Reflow version: %s\n", GetVersion())
35+
repo := GetRepository()
36+
if repo != "" {
37+
fmt.Printf("Source Repository: https://github.com/%s\n", repo)
38+
}
39+
},
40+
}
41+
rootCmd.AddCommand(versionCmd)
42+
}
43+
44+
// GetVersion returns the embedded version string.
45+
func GetVersion() string {
46+
if version == "dev" && buildInfo != nil && buildInfo.Main.Version != "" && buildInfo.Main.Version != "(devel)" {
47+
return buildInfo.Main.Version
48+
}
49+
return version
50+
}
51+
52+
// GetRepository returns the embedded repository slug (owner/repo).
53+
func GetRepository() string {
54+
if repository != "" {
55+
return repository
56+
}
57+
if buildInfo != nil && buildInfo.Main.Path != "" {
58+
pathParts := strings.Split(buildInfo.Main.Path, "/")
59+
if len(pathParts) >= 3 && pathParts[0] == "github.com" {
60+
repo := fmt.Sprintf("%s/%s", pathParts[1], pathParts[2])
61+
return repo
62+
}
63+
}
64+
if repository == "" {
65+
return "RevereInc/reflow"
66+
}
67+
return ""
68+
}

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ require (
2323
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
2424
github.com/gogo/protobuf v1.3.2 // indirect
2525
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
26+
github.com/hashicorp/go-version v1.7.0 // indirect
2627
github.com/inconshreveable/mousetrap v1.1.0 // indirect
2728
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
2829
github.com/kevinburke/ssh_config v1.2.0 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
4949
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
5050
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
5151
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
52+
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
53+
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
5254
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
5355
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
5456
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=

internal/orchestrator/approve.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ func ApproveProd(ctx context.Context, reflowBasePath, projectName string) (err e
117117
}
118118

119119
util.Log.Debugf("Loading environment variables from file: %s", envFilePath)
120-
envVars, err := loadEnvFile(envFilePath)
120+
envVars, err := util.LoadEnvFile(envFilePath)
121121
if err != nil {
122122
return fmt.Errorf("failed to load prod environment variables: %w", err)
123123
}

internal/orchestrator/deploy.go

Lines changed: 1 addition & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package orchestrator
22

33
import (
4-
"bufio"
54
"context"
65
"fmt"
76
"os"
@@ -170,7 +169,7 @@ func DeployTest(ctx context.Context, reflowBasePath, projectName, commitIsh stri
170169
envFilePath = filepath.Join(repoPath, projCfg.Environments["test"].EnvFile)
171170
}
172171

173-
envVars, err := loadEnvFile(envFilePath)
172+
envVars, err := util.LoadEnvFile(envFilePath)
174173
if err != nil {
175174
return fmt.Errorf("failed to load environment variables: %w", err)
176175
}
@@ -297,50 +296,3 @@ func DeployTest(ctx context.Context, reflowBasePath, projectName, commitIsh stri
297296

298297
return nil
299298
}
300-
301-
// loadEnvFile loads environment variables from a specified file.
302-
func loadEnvFile(filePath string) ([]string, error) {
303-
var vars []string
304-
if filePath == "" {
305-
util.Log.Debug("No env file path specified.")
306-
return vars, nil
307-
}
308-
309-
file, err := os.Open(filePath)
310-
if err != nil {
311-
if os.IsNotExist(err) {
312-
util.Log.Warnf("Environment file not found at %s, continuing without it.", filePath)
313-
return vars, nil
314-
}
315-
return nil, fmt.Errorf("failed to open env file %s: %w", filePath, err)
316-
}
317-
defer func(file *os.File) {
318-
err := file.Close()
319-
if err != nil {
320-
util.Log.Errorf("Error closing env file %s: %v", filePath, err)
321-
} else {
322-
util.Log.Debugf("Closed env file %s successfully.", filePath)
323-
}
324-
}(file)
325-
326-
scanner := bufio.NewScanner(file)
327-
lineNumber := 0
328-
for scanner.Scan() {
329-
lineNumber++
330-
line := strings.TrimSpace(scanner.Text())
331-
if line == "" || strings.HasPrefix(line, "#") {
332-
continue
333-
}
334-
if !strings.Contains(line, "=") {
335-
util.Log.Warnf("Skipping invalid line %d in env file %s: Missing '='", lineNumber, filePath)
336-
continue
337-
}
338-
vars = append(vars, line)
339-
}
340-
341-
if err := scanner.Err(); err != nil {
342-
return nil, fmt.Errorf("error reading env file %s: %w", filePath, err)
343-
}
344-
util.Log.Debugf("Loaded %d variables from %s", len(vars), filePath)
345-
return vars, nil
346-
}

0 commit comments

Comments
 (0)