Merge branch 'feat/auto-update' into dev

This commit is contained in:
Mathis Maquenne
2026-07-30 11:32:12 +02:00
18 changed files with 961 additions and 11 deletions
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
#
# Extracts the CHANGELOG.md section for a version and formats it as the GitHub
# release body. Usage: release-notes.sh v1.5.0
#
# Exits non-zero when the version has no section, so a tag can never be
# published with empty or stale release notes.
set -euo pipefail
if [ $# -ne 1 ]; then
echo "usage: $0 <version>" >&2
exit 2
fi
version="${1#v}"
changelog="${CHANGELOG_FILE:-CHANGELOG.md}"
if [ ! -f "$changelog" ]; then
echo "$changelog not found" >&2
exit 1
fi
notes=$(awk -v ver="$version" '
BEGIN { heading = "## [" ver "]" }
index($0, heading) == 1 { found = 1; next }
found && index($0, "## [") == 1 { exit }
found { print }
' "$changelog")
if [ -z "${notes//[[:space:]]/}" ]; then
echo "no $changelog section found for version $version" >&2
exit 1
fi
printf "## What's new in v%s\n%s\n" "$version" "$notes"
+38
View File
@@ -0,0 +1,38 @@
name: ci
on:
push:
branches: ['**']
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Check formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "Not gofmt'd:"
echo "$unformatted"
exit 1
fi
- run: go vet ./...
- run: go build ./...
# Catches a broken release config before a tag is pushed.
- uses: goreleaser/goreleaser-action@v6
with:
version: '~> v2'
args: check
+33
View File
@@ -0,0 +1,33 @@
name: release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# Fails the release if CHANGELOG.md has no section for this tag.
- name: Build release notes from CHANGELOG.md
run: .github/scripts/release-notes.sh "${{ github.ref_name }}" > "${RUNNER_TEMP}/release-notes.md"
- uses: goreleaser/goreleaser-action@v6
with:
version: '~> v2'
args: release --clean --release-notes=${{ runner.temp }}/release-notes.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+47
View File
@@ -0,0 +1,47 @@
version: 2
project_name: godeez
before:
hooks:
- go mod tidy
builds:
- id: godeez
main: .
binary: godeez
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w
- -X github.com/mathismqn/godeez/internal/buildinfo.version={{ .Version }}
- -X github.com/mathismqn/godeez/internal/buildinfo.commit={{ .FullCommit }}
- -X github.com/mathismqn/godeez/internal/buildinfo.date={{ .CommitDate }}
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
archives:
- formats:
- binary
name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}'
checksum:
name_template: checksums.txt
algorithm: sha256
snapshot:
version_template: '{{ incpatch .Version }}-snapshot'
# Release notes are written by hand in CHANGELOG.md
changelog:
disable: true
release:
prerelease: auto
+54 -8
View File
@@ -10,10 +10,11 @@ A simple Go tool for downloading music from [Deezer](https://www.deezer.com).
[Features](#features) • [Features](#features) •
[Installation](#installation) • [Installation](#installation) •
[Updating](#updating) •
[Configuration](#configuration) • [Configuration](#configuration) •
[Usage](#usage) • [Usage](#usage) •
[Contributing](#contributing) • [Contributing](#contributing) •
[Support](#⭐-support-the-project) • [Support](#support-the-project) •
[License](#license) [License](#license)
</div> </div>
@@ -33,14 +34,58 @@ A simple Go tool for downloading music from [Deezer](https://www.deezer.com).
To install **GoDeez**, download the latest binary for your platform from the [Releases](https://github.com/mathismqn/godeez/releases) page. To install **GoDeez**, download the latest binary for your platform from the [Releases](https://github.com/mathismqn/godeez/releases) page.
1. Go to the [Releases](https://github.com/mathismqn/godeez/releases) page. 1. Go to the [Releases](https://github.com/mathismqn/godeez/releases) page.
2. Download the appropriate binary for your operating system (Windows, macOS, or Linux). 2. Download the appropriate binary for your operating system and architecture, named `godeez_<version>_<os>_<arch>`.
3. (Optional) Move the binary to a directory included in `$PATH` for easier access. 3. (Optional) Move the binary to a directory included in `$PATH` for easier access.
Example (Linux/macOS): Example (Linux/macOS):
```bash ```bash
# Move the downloaded binary to /usr/local/bin for easy access from anywhere # Make it executable and move it to /usr/local/bin for access from anywhere
mv godeez-1.4.0-linux-amd64 /usr/local/bin/godeez chmod +x godeez_1.5.0_linux_amd64
mv godeez_1.5.0_linux_amd64 /usr/local/bin/godeez
```
Every release also ships a `checksums.txt`, so you can verify a download:
```bash
sha256sum -c checksums.txt --ignore-missing
```
### macOS
The macOS binaries are not signed with an Apple Developer certificate, so
Gatekeeper blocks them the first time. Clear the quarantine flag once:
```bash
xattr -d com.apple.quarantine /usr/local/bin/godeez
```
## Updating
**GoDeez** can replace itself with the latest release:
```bash
# See whether a new version exists
godeez update --check
# Download, verify, and install it
godeez update
```
The new binary is verified against the release's published SHA256 checksum
before it replaces the current one. If **GoDeez** lives in a directory you do
not own (such as `/usr/local/bin` on some systems), run `sudo godeez update`.
To disable the notice about new versions:
```bash
export GODEEZ_NO_UPDATE_CHECK=1
```
To see what you are running:
```bash
godeez version
``` ```
## Configuration ## Configuration
@@ -48,7 +93,7 @@ mv godeez-1.4.0-linux-amd64 /usr/local/bin/godeez
**GoDeez** requires a Deezer ARL cookie for authentication. Set it as an environment variable: **GoDeez** requires a Deezer ARL cookie for authentication. Set it as an environment variable:
```bash ```bash
export DEEZER_ARL=your_arl_cookie_here export DEEZER_ARL="your_arl_cookie_here"
``` ```
To make it persistent, add the line above to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.). To make it persistent, add the line above to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.).
@@ -85,6 +130,8 @@ Available Commands:
completion Generate the autocompletion script for the specified shell completion Generate the autocompletion script for the specified shell
download Download songs from Deezer download Download songs from Deezer
help Help about any command help Help about any command
update Update GoDeez to the latest version
version Print the GoDeez version
Flags: Flags:
-h, --help help for godeez -h, --help help for godeez
@@ -108,7 +155,6 @@ Available Commands:
Flags: Flags:
--bpm fetch BPM/key and add to file tags --bpm fetch BPM/key and add to file tags
--config string config file (default ~/.godeez/config.toml)
--genre fetch genre and add to file tags --genre fetch genre and add to file tags
-h, --help help for download -h, --help help for download
-q, --quality string download quality [mp3_128, mp3_320, flac] (default "mp3_320") -q, --quality string download quality [mp3_128, mp3_320, flac] (default "mp3_320")
@@ -144,9 +190,9 @@ Whether its a bug fix, a new feature, or improving documentation, your input
If you have an idea for improvement, feel free to fork the repository and submit a pull request. You can also open an issue if you spot a bug or have a feature suggestion. If you have an idea for improvement, feel free to fork the repository and submit a pull request. You can also open an issue if you spot a bug or have a feature suggestion.
## Support the Project ## Support the Project
If **GoDeez** helps you enjoy your music collection, please consider giving it a star! If **GoDeez** helps you enjoy your music collection, please consider giving it a !
**Why star us?** **Why star us?**
+1
View File
@@ -21,6 +21,7 @@ var opts downloader.Options
var downloadCmd = &cobra.Command{ var downloadCmd = &cobra.Command{
Use: "download", Use: "download",
Short: "Download songs from Deezer", Short: "Download songs from Deezer",
Annotations: map[string]string{updateNoticeAnnotation: "true"},
} }
func init() { func init() {
+64
View File
@@ -1,11 +1,75 @@
package cmd package cmd
import ( import (
"context"
"fmt"
"os"
"slices"
"github.com/mathismqn/godeez/internal/buildinfo"
"github.com/mathismqn/godeez/internal/updater"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/term"
) )
const updateNoticeAnnotation = "godeez:update-notice"
var RootCmd = &cobra.Command{ var RootCmd = &cobra.Command{
Use: "godeez", Use: "godeez",
Short: "GoDeez is a tool to download music from Deezer", Short: "GoDeez is a tool to download music from Deezer",
SilenceUsage: true, SilenceUsage: true,
} }
func Execute(ctx context.Context) error {
var notice <-chan string
if wantsUpdateNotice() {
notice = updater.StartCheck(ctx)
}
err := RootCmd.ExecuteContext(ctx)
printUpdateNotice(notice)
return err
}
func wantsUpdateNotice() bool {
if !term.IsTerminal(int(os.Stderr.Fd())) {
return false
}
args := os.Args[1:]
if slices.Contains(args, "-h") || slices.Contains(args, "--help") {
return false
}
target, _, err := RootCmd.Find(args)
if err != nil || target == nil {
return false
}
if target.Run == nil && target.RunE == nil {
return false
}
for cmd := target; cmd != nil; cmd = cmd.Parent() {
if cmd.Annotations[updateNoticeAnnotation] == "true" {
return true
}
}
return false
}
func printUpdateNotice(notice <-chan string) {
select {
case latest := <-notice:
if latest == "" {
return
}
fmt.Fprintf(os.Stderr, "\n ┌ Update available: %s → %s\n └ Run `godeez update` to install\n",
buildinfo.Version(), latest)
default:
}
}
+75
View File
@@ -0,0 +1,75 @@
package cmd
import (
"context"
"errors"
"fmt"
"os"
"github.com/mathismqn/godeez/internal/buildinfo"
"github.com/mathismqn/godeez/internal/updater"
"github.com/spf13/cobra"
)
var updateOpts struct {
checkOnly bool
force bool
}
var updateCmd = &cobra.Command{
Use: "update",
Short: "Update GoDeez to the latest version",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
err := runUpdate(cmd.Context())
if errors.Is(err, context.Canceled) {
return nil
}
return err
},
}
func runUpdate(ctx context.Context) error {
if err := updater.CheckUpdatable(); err != nil {
return err
}
u := updater.New()
u.Out = os.Stdout
current := buildinfo.Version()
release, err := u.Latest(ctx)
if err != nil {
return fmt.Errorf("failed to check for updates: %w", err)
}
latest := release.Version()
fmt.Printf("Current: %s\nLatest: %s\n", current, latest)
if !updater.IsNewer(current, latest) && !updateOpts.force {
fmt.Println("Already up to date.")
return nil
}
if updateOpts.checkOnly {
fmt.Printf("Run `godeez update` to install %s.\n", latest)
return nil
}
if err := u.Apply(ctx, release); err != nil {
return err
}
fmt.Printf("Updated to %s.\n", latest)
return nil
}
func init() {
RootCmd.AddCommand(updateCmd)
updateCmd.Flags().BoolVar(&updateOpts.checkOnly, "check", false, "only report whether an update is available")
updateCmd.Flags().BoolVar(&updateOpts.force, "force", false, "reinstall even if already up to date")
}
+32
View File
@@ -0,0 +1,32 @@
package cmd
import (
"fmt"
"runtime"
"github.com/mathismqn/godeez/internal/buildinfo"
"github.com/spf13/cobra"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print the current version of GoDeez",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("godeez %s\n", buildinfo.Version())
if commit := buildinfo.Commit(); commit != "" {
fmt.Printf(" commit: %s\n", commit)
}
if date := buildinfo.Date(); date != "" {
fmt.Printf(" built: %s\n", date)
}
fmt.Printf(" go: %s\n", runtime.Version())
fmt.Printf(" platform: %s/%s\n", runtime.GOOS, runtime.GOARCH)
},
}
func init() {
RootCmd.AddCommand(versionCmd)
}
+1
View File
@@ -5,6 +5,7 @@ go 1.25.0
require ( require (
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8 github.com/zalando/go-keyring v0.2.8
golang.org/x/mod v0.38.0
golang.org/x/term v0.45.0 golang.org/x/term v0.45.0
) )
+2
View File
@@ -52,6 +52,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+74
View File
@@ -0,0 +1,74 @@
package buildinfo
import (
"fmt"
"runtime"
"runtime/debug"
"strings"
"golang.org/x/mod/module"
"golang.org/x/mod/semver"
)
// devVersion is the version reported by builds that were not produced by a
// release. Both the update check and `godeez update` refuse to run on them.
const devVersion = "dev"
var (
version = devVersion
commit = ""
date = ""
)
func Version() string {
if version != devVersion {
return version
}
if info, ok := debug.ReadBuildInfo(); ok {
if v := releaseVersion(info.Main.Version); v != "" {
return v
}
}
return devVersion
}
func releaseVersion(v string) string {
if !semver.IsValid(v) {
return ""
}
if semver.Build(v) != "" || module.IsPseudoVersion(v) {
return ""
}
return strings.TrimPrefix(v, "v")
}
func IsDev() bool {
return Version() == devVersion
}
func Commit() string {
if commit != "" {
return commit
}
if info, ok := debug.ReadBuildInfo(); ok {
for _, s := range info.Settings {
if s.Key == "vcs.revision" {
return s.Value
}
}
}
return ""
}
func Date() string {
return date
}
func UserAgent() string {
return fmt.Sprintf("godeez/%s (%s/%s)", Version(), runtime.GOOS, runtime.GOARCH)
}
+208
View File
@@ -0,0 +1,208 @@
package updater
import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/mathismqn/godeez/internal/buildinfo"
"github.com/mathismqn/godeez/internal/fileutil"
)
var managedPrefixes = []string{
"/nix/store",
"/opt/homebrew",
"/usr/local/Cellar",
"/home/linuxbrew",
"/snap",
"/var/lib/flatpak",
}
func resolveTarget() (string, error) {
if buildinfo.IsDev() {
return "", fmt.Errorf("development build cannot self-update. Install a release from https://github.com/%s/%s/releases",
repoOwner, repoName)
}
exe, err := os.Executable()
if err != nil {
return "", fmt.Errorf("failed to locate the running binary: %w", err)
}
target := exe
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
target = resolved
}
for _, prefix := range managedPrefixes {
if strings.HasPrefix(target, prefix) {
return "", fmt.Errorf("%s was installed by a package manager. Update it with that instead", target)
}
}
return target, nil
}
func CheckUpdatable() error {
_, err := resolveTarget()
return err
}
func checkWritable(dir string) error {
f, err := os.CreateTemp(dir, tmpPattern)
if err != nil {
hint := "Re-run with sudo"
if runtime.GOOS == "windows" {
hint = "Re-run from an elevated prompt"
}
return fmt.Errorf("cannot write to %s: %w. %s", dir, err, hint)
}
name := f.Name()
f.Close()
os.Remove(name)
return nil
}
func (u *Updater) Apply(ctx context.Context, release *Release) error {
target, err := resolveTarget()
if err != nil {
return err
}
dir := filepath.Dir(target)
if err := checkWritable(dir); err != nil {
return err
}
asset, err := release.assetForRuntime()
if err != nil {
return err
}
want, err := u.fetchChecksum(ctx, release, asset.Name)
if err != nil {
return err
}
u.step("Downloading %s", asset.Name)
tmp, sum, err := u.download(ctx, dir, asset)
if err != nil {
return err
}
defer fileutil.DeleteFile(tmp)
u.step("Verifying checksum")
if sum != want {
return fmt.Errorf("checksum mismatch for %s: expected %s, got %s", asset.Name, want, sum)
}
if err := os.Chmod(tmp, 0755); err != nil {
return err
}
u.step("Replacing %s", target)
return replaceBinary(target, tmp)
}
func (u *Updater) fetchChecksum(ctx context.Context, release *Release, assetName string) (string, error) {
asset, ok := release.asset(checksumsAsset)
if !ok {
return "", fmt.Errorf("release %s does not publish %s", release.TagName, checksumsAsset)
}
ctx, cancel := context.WithTimeout(ctx, apiTimeout)
defer cancel()
body, err := u.get(ctx, asset.URL, nil)
if err != nil {
return "", err
}
defer body.Close()
return parseChecksums(io.LimitReader(body, maxResponseSize), assetName)
}
func parseChecksums(r io.Reader, name string) (string, error) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) != 2 {
continue
}
if strings.TrimPrefix(fields[1], "*") == name {
return strings.ToLower(fields[0]), nil
}
}
if err := scanner.Err(); err != nil {
return "", err
}
return "", fmt.Errorf("no checksum listed for %s", name)
}
func (u *Updater) download(ctx context.Context, dir string, asset Asset) (string, string, error) {
body, err := u.get(ctx, asset.URL, nil)
if err != nil {
return "", "", err
}
defer body.Close()
f, err := os.CreateTemp(dir, tmpPattern)
if err != nil {
return "", "", err
}
tmp := f.Name()
hash := sha256.New()
if _, err := io.Copy(io.MultiWriter(f, hash), body); err != nil {
f.Close()
fileutil.DeleteFile(tmp)
return "", "", fmt.Errorf("failed to download %s: %w", asset.Name, err)
}
if err := f.Close(); err != nil {
fileutil.DeleteFile(tmp)
return "", "", err
}
return tmp, hex.EncodeToString(hash.Sum(nil)), nil
}
func replaceBinary(target, tmp string) error {
if runtime.GOOS != "windows" {
return os.Rename(tmp, target)
}
old := target + ".old"
os.Remove(old)
if err := os.Rename(target, old); err != nil {
return fmt.Errorf("failed to move the current binary aside: %w", err)
}
if err := os.Rename(tmp, target); err != nil {
if rollbackErr := os.Rename(old, target); rollbackErr != nil {
return fmt.Errorf("failed to install the new binary: %w. The previous one could not be restored from %s: %v",
err, old, rollbackErr)
}
return fmt.Errorf("failed to install the new binary: %w", err)
}
os.Remove(old)
return nil
}
+118
View File
@@ -0,0 +1,118 @@
package updater
import (
"context"
"encoding/json"
"os"
"path/filepath"
"time"
"github.com/mathismqn/godeez/internal/buildinfo"
"github.com/mathismqn/godeez/internal/fileutil"
)
const noCheckEnv = "GODEEZ_NO_UPDATE_CHECK"
const (
cacheTTL = 24 * time.Hour
checkTimeout = 3 * time.Second
)
type cacheEntry struct {
CheckedAt time.Time `json:"checked_at"`
LatestVersion string `json:"latest_version"`
}
func cachePath() (string, error) {
dir, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "godeez", "update.json"), nil
}
func readCache() (cacheEntry, bool) {
path, err := cachePath()
if err != nil {
return cacheEntry{}, false
}
data, err := os.ReadFile(path)
if err != nil {
return cacheEntry{}, false
}
var entry cacheEntry
if err := json.Unmarshal(data, &entry); err != nil {
return cacheEntry{}, false
}
if entry.LatestVersion == "" || time.Since(entry.CheckedAt) > cacheTTL {
return cacheEntry{}, false
}
return entry, true
}
func writeCache(version string) error {
path, err := cachePath()
if err != nil {
return err
}
if err := fileutil.EnsureDir(filepath.Dir(path)); err != nil {
return err
}
data, err := json.Marshal(cacheEntry{CheckedAt: time.Now(), LatestVersion: version})
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
func check(ctx context.Context) (string, error) {
if entry, ok := readCache(); ok {
return newerThanCurrent(entry.LatestVersion), nil
}
release, err := New().Latest(ctx)
if err != nil {
return "", err
}
latest := release.Version()
_ = writeCache(latest)
return newerThanCurrent(latest), nil
}
func newerThanCurrent(latest string) string {
if IsNewer(buildinfo.Version(), latest) {
return latest
}
return ""
}
func StartCheck(ctx context.Context) <-chan string {
ch := make(chan string, 1)
if os.Getenv(noCheckEnv) != "" || buildinfo.IsDev() {
close(ch)
return ch
}
go func() {
defer close(ch)
ctx, cancel := context.WithTimeout(ctx, checkTimeout)
defer cancel()
if latest, err := check(ctx); err == nil && latest != "" {
ch <- latest
}
}()
return ch
}
+82
View File
@@ -0,0 +1,82 @@
package updater
import (
"context"
"encoding/json"
"fmt"
"io"
"runtime"
)
const (
repoOwner = "mathismqn"
repoName = "godeez"
latestReleaseURL = "https://api.github.com/repos/" + repoOwner + "/" + repoName + "/releases/latest"
checksumsAsset = "checksums.txt"
maxResponseSize = 1 << 20
)
var githubAPIHeaders = map[string]string{
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
type Release struct {
TagName string `json:"tag_name"`
Assets []Asset `json:"assets"`
}
type Asset struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
}
func (u *Updater) Latest(ctx context.Context) (*Release, error) {
ctx, cancel := context.WithTimeout(ctx, apiTimeout)
defer cancel()
body, err := u.get(ctx, latestReleaseURL, githubAPIHeaders)
if err != nil {
return nil, err
}
defer body.Close()
var release Release
if err := json.NewDecoder(io.LimitReader(body, maxResponseSize)).Decode(&release); err != nil {
return nil, fmt.Errorf("failed to decode release: %w", err)
}
if release.TagName == "" {
return nil, fmt.Errorf("release has no tag name")
}
return &release, nil
}
func (r *Release) Version() string {
return trimV(r.TagName)
}
func (r *Release) asset(name string) (Asset, bool) {
for _, a := range r.Assets {
if a.Name == name {
return a, true
}
}
return Asset{}, false
}
func (r *Release) assetForRuntime() (Asset, error) {
name := fmt.Sprintf("%s_%s_%s_%s", repoName, r.Version(), runtime.GOOS, runtime.GOARCH)
if runtime.GOOS == "windows" {
name += ".exe"
}
asset, ok := r.asset(name)
if !ok {
return Asset{}, fmt.Errorf("release %s has no binary for %s/%s (expected %s)",
r.TagName, runtime.GOOS, runtime.GOARCH, name)
}
return asset, nil
}
+55
View File
@@ -0,0 +1,55 @@
package updater
import (
"context"
"fmt"
"io"
"net/http"
"time"
"github.com/mathismqn/godeez/internal/buildinfo"
)
const (
apiTimeout = 30 * time.Second
tmpPattern = ".godeez-update-*"
)
type Updater struct {
client *http.Client
Out io.Writer
}
func New() *Updater {
return &Updater{
client: &http.Client{},
Out: io.Discard,
}
}
func (u *Updater) step(format string, args ...any) {
fmt.Fprintf(u.Out, format+"...\n", args...)
}
func (u *Updater) get(ctx context.Context, url string, headers map[string]string) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", buildinfo.UserAgent())
for name, value := range headers {
req.Header.Set(name, value)
}
resp, err := u.client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("unexpected status code %d from %s", resp.StatusCode, url)
}
return resp.Body, nil
}
+35
View File
@@ -0,0 +1,35 @@
package updater
import (
"strings"
"golang.org/x/mod/semver"
)
func trimV(v string) string {
return strings.TrimPrefix(strings.TrimSpace(v), "v")
}
func canonical(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
if !strings.HasPrefix(v, "v") {
v = "v" + v
}
if !semver.IsValid(v) {
return ""
}
return v
}
func IsNewer(current, latest string) bool {
c, l := canonical(current), canonical(latest)
if c == "" || l == "" {
return false
}
return semver.Compare(l, c) > 0
}
+4 -1
View File
@@ -12,5 +12,8 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop() defer stop()
cmd.RootCmd.ExecuteContext(ctx) if err := cmd.Execute(ctx); err != nil {
stop()
os.Exit(1)
}
} }