[mike@mwxm4]

This commit is contained in:
2026-08-12 17:03:43 +02:00
parent 4ad3d2e13d
commit 94f5ec969d
11 changed files with 847 additions and 209 deletions
+1
View File
@@ -17,3 +17,4 @@
bin/* bin/*
tmp/* tmp/*
gbld gbld
.mgshrc
+37 -10
View File
@@ -11,8 +11,7 @@
- 🧹 **Auto-Import**: Runs `goimports` on save (optional via `-i`) to keep your imports clean — without touching the rest of your formatting. - 🧹 **Auto-Import**: Runs `goimports` on save (optional via `-i`) to keep your imports clean — without touching the rest of your formatting.
- 🖥️ **Interactive Menu**: Uses a select menu (`gbld.menu.json`) if run without arguments, letting you define and run custom build tasks. - 🖥️ **Interactive Menu**: Uses a select menu (`gbld.menu.json`) if run without arguments, letting you define and run custom build tasks.
- 🚀 **Cross-Compilation**: Easily builds binaries for multiple target operating systems (macOS, Linux, Windows) and architectures (amd64, arm64) using a single command. - 🚀 **Cross-Compilation**: Easily builds binaries for multiple target operating systems (macOS, Linux, Windows) and architectures (amd64, arm64) using a single command.
- ☁️ **Remote Uploads**: Integrated command to upload build artifacts to a deployment server (`gozilla`). - 🔐 **Verified Self-Update**: Installs new versions from the Gitea releases over HTTPS and refuses anything that does not match its published SHA-256 checksum.
- 🔐 **Verified Self-Update**: Downloads updates over HTTPS and refuses to install anything that does not match its published SHA-256 checksum.
--- ---
@@ -53,15 +52,16 @@ gbld [flags] [target_name]
| `-o` | `<args>` | Space-separated arguments to pass to the compiled binary on execution. | | `-o` | `<args>` | Space-separated arguments to pass to the compiled binary on execution. |
| `-c` | `<command>` | Command to execute immediately after a successful build (before execution). | | `-c` | `<command>` | Command to execute immediately after a successful build (before execution). |
| `-a` | None | Builds binaries for all supported platforms (Windows, macOS, Linux for AMD64/ARM64). | | `-a` | None | Builds binaries for all supported platforms (Windows, macOS, Linux for AMD64/ARM64). |
| `-u` | None | Uploads built binaries via SSH/SCP to the `gozilla` server. |
| `-v` | None | Displays the current version of `gbld`. | | `-v` | None | Displays the current version of `gbld`. |
| `-h` | None | Displays help and usage information. | | `-h` | None | Displays help and usage information. |
| `--check-update` | None | Looks for a newer release and reports what it finds — installs nothing. |
| `--update` | None | Downloads the newest release and replaces the running binary with it. |
### Environment ### Environment
| Variable | Description | | Variable | Description |
| :--- | :--- | | :--- | :--- |
| `GBLD_NO_UPDATE` | Set to any value to skip the update check at startup (useful offline or in CI). | | `GBLD_NO_UPDATE` | Set to any value to silence the background update check (useful offline or in CI). `--update` and `--check-update` still work. |
--- ---
@@ -85,7 +85,7 @@ To make running common tasks easier, you can place a `gbld.menu.json` file in yo
When compiling, `gbld` parses `build.go` to find a pattern matching `var build = "<number>"`. It increments this number by 1 and updates the file automatically, leaving the rest of the line (including trailing comments) untouched. This is useful for stamping build numbers/versions inside your binary. If there is no `build.go`, this step is silently skipped. When compiling, `gbld` parses `build.go` to find a pattern matching `var build = "<number>"`. It increments this number by 1 and updates the file automatically, leaving the rest of the line (including trailing comments) untouched. This is useful for stamping build numbers/versions inside your binary. If there is no `build.go`, this step is silently skipped.
### Target Version ### Target Version
`-a` and `-u` name their artifacts after the first `var version = "x.y.z"` found in the `*.go` files of the project. Ordinary builds do not need this variable. `-a` names its artifacts after the first `var version = "x.y.z"` found in the `*.go` files of the project. Ordinary builds do not need this variable.
### Automatic Backups ### Automatic Backups
Before `gbld` runs `goimports` on a file or modifies `build.go`, it creates a timestamped backup in a `./tmp/` directory inside the project workspace (the directory is created on demand): Before `gbld` runs `goimports` on a file or modifies `build.go`, it creates a timestamped backup in a `./tmp/` directory inside the project workspace (the directory is created on demand):
@@ -116,22 +116,49 @@ With `-1`, `gbld` is usable from scripts and CI:
--- ---
## Cross-Compilation and Upload ## Cross-Compilation
```bash ```bash
gbld -a # build bin/<name>_<version>_<os>_<arch> for all 6 targets gbld -a # build bin/<name>_<version>_<os>_<arch> for all 6 targets
gbld -u # upload them to gozilla
``` ```
`-a` writes Windows artifacts with an `.exe` suffix and additionally produces: `-a` writes Windows artifacts with an `.exe` suffix and additionally produces:
- `bin/checksums.txt` — SHA-256 of every artifact, in the format used by `shasum -a 256 -c` - `bin/checksums.txt` — SHA-256 of every artifact, in the format used by `shasum -a 256 -c`
- `bin/version.txt` — the published version - `bin/version.txt` — the version that was built
If any target fails to build, `version.txt` is **not** written and `gbld` exits with `1`, so a half-published version can never be announced. `-u` refuses build names containing anything but `A-Za-z0-9._-` and uploads the artifacts together with `checksums.txt` and `version.txt`. If any target fails to build, `version.txt` is **not** written and `gbld` exits with `1`, so a half-published version can never be announced.
> [!NOTE]
> `-a` is the path for the programs `gbld` builds. `gbld` itself is released with `build.sh` (see below), which uses a different naming — both write `bin/checksums.txt`, so whichever ran last is the one that file describes.
---
## Releasing gbld itself
```bash
./build.sh # steps the patch version, builds every platform
VERSION=1.22.0 ./build.sh # a minor or major step is named outright
PLATFORMS="linux/amd64" ./build.sh
```
`build.sh` writes into `bin/` exactly what a release needs:
- `gbld-<goos>-<goarch>` — the binaries, `.exe` for Windows, built with `-trimpath -s -w` and the version injected via `-ldflags`
- `checksums.txt` — their SHA-256, in the format of `shasum -a 256`
`version.txt` holds the version just built and is stepped by `0.0.1` on every run. Upload **all** of these files to a Gitea release whose tag is the bare version number (`1.21.1`, a leading `v` is allowed) — that is the layout `--update` expects.
> [!IMPORTANT]
> `gbld -a` names its artifacts from `var version` in the sources, `build.sh` from `version.txt`, and nothing writes to a `.go` file. For gbld's own releases `build.sh` is the way — `var version` only shows up in a bare `go build`.
### Self-Update ### Self-Update
On startup `gbld` fetches `version.txt` from the update server (3 s timeout — an unreachable server never blocks a build). If a newer version is published, it asks before downloading, then verifies the download against the SHA-256 from `checksums.txt` before replacing itself. **If no matching checksum is published, the update is refused.** Publish new versions with `gbld -a` followed by `gbld -u`.
`selfupdate.go` holds the whole mechanism and is meant to be copied into other programs: adjust the block at its head, hang the two options into the flags, done. It needs nothing but the standard library.
Once a day, in the background, `gbld` asks the Gitea for the newest release and notes the answer in the cache directory. The next run reads that note and prints one line if something newer exists — the foreground never touches the network, so an unreachable server can never delay a build. The hint appears only on a terminal: in a pipe, a script or under cron, `gbld` stays quiet, as it does with `GBLD_NO_UPDATE` set.
`--update` fetches the asset for the running platform, weighs it against the SHA-256 from the release's `checksums.txt`, and only then replaces the running binary — after calling the fresh one once with `-v` to be sure it runs at all. **A release without a matching checksum is refused**, and a download that does not match is deleted, not installed. The certificate of the Gitea is verified like any other.
--- ---
+1 -1
View File
@@ -1,3 +1,3 @@
package main package main
var build = "368" var build = "369"
Executable
+97
View File
@@ -0,0 +1,97 @@
#!/bin/sh
# Build gbld for the usual platforms into ./bin, auto-incrementing the patch
# version by 0.0.1 on every build.
#
# version.txt holds the currently built version. Each run increments the patch
# component, then builds every platform with that one version injected via
# -ldflags, and writes it back. So version.txt always reflects the version of
# the binaries just built, and all of them carry the same one.
#
# What ends up in ./bin is exactly what a release wants:
#
# gbld-<goos>-<goarch> the binaries, '.exe' for windows
# checksums.txt their sha256, in the format of `shasum -a 256`
#
# selfupdate.go looks for both. Upload all of them to a Gitea release whose tag
# is the bare version number, and --update finds them; without checksums.txt it
# refuses to install anything, so do not leave it behind.
#
# This is not the naming of gbld's own -a, which builds
# "<name>_<version>_<os>_<arch>" — that path is for the programs gbld builds,
# and it reads 'var version' out of the sources rather than version.txt.
# Both write bin/checksums.txt, each for its own set of names.
#
# Override the platform list to build just one:
# PLATFORMS="linux/amd64" ./build.sh
#
# A plain run only ever steps the patch. A minor or major step is taken by
# naming the version outright, which is then written back like any other:
# VERSION=1.22.0 ./build.sh
#
# build.go carries a separate build counter that shows up in -v next to the
# version; nothing here touches it — gbld itself steps that one on every
# compile it does.
#
# -s -w drops the symbol table and DWARF info, -trimpath keeps build paths out
# of the binary; together they roughly halve it. Neither affects a panic trace.
set -e
cd "$(dirname "$0")"
PLATFORMS=${PLATFORMS:-"darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64 windows/arm64"}
if [ -n "$VERSION" ]; then
NV="$VERSION"
else
V=$(cat version.txt 2>/dev/null || echo 1.21.0)
# split MAJOR.MINOR.PATCH and increment PATCH (no carry: 1.21.9 -> 1.21.10)
MAJOR=${V%%.*}
REST=${V#*.}
MINOR=${REST%%.*}
PATCH=${REST#*.}
PATCH=$((PATCH + 1))
NV="$MAJOR.$MINOR.$PATCH"
fi
# Linux brings sha256sum, macOS brings shasum; both write "<hash> <name>".
sha256() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$@"
else
shasum -a 256 "$@"
fi
}
mkdir -p bin
HOST="$(go env GOOS)/$(go env GOARCH)"
BUILT=""
for p in $PLATFORMS; do
os=${p%/*}
arch=${p#*/}
name="gbld-$os-$arch"
[ "$os" = "windows" ] && name="$name.exe"
# CGO_ENABLED=0 throughout: it makes the cross builds work without a
# toolchain per target and the binaries static. gbld only needs the
# filesystem, the network and a shell, so nothing is lost by dropping cgo.
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \
go build -trimpath -ldflags "-s -w -X main.version=$NV" -o "bin/$name" .
BUILT="$BUILT $name"
if [ "$p" = "$HOST" ]; then
ln -sf "$name" bin/gbld # the one for this machine
echo " bin/$name -> bin/gbld"
else
echo " bin/$name"
fi
done
# Only what this run built: a checksum for a file from an earlier version would
# be a checksum for a binary nobody is publishing.
(cd bin && sha256 $BUILT > checksums.txt)
echo " bin/checksums.txt"
echo "$NV" > version.txt
echo "built gbld v$NV"
+32 -78
View File
@@ -24,11 +24,12 @@ import (
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
) )
var version = "1.21.0" // version is a var, not a const, so build.sh can put the built number in via
// -ldflags "-X main.version=…". The value here only ever shows up in a bare
var UPDATEURL = "https://gozilla.fhi.mpg.de/gbld" // `go build`; the version a release carries is the one in version.txt. Note
var UPLOADHOST = "root@gozilla" // that -a below names its artifacts from this line and not from version.txt —
var UPLOADPATH = "/db/www" // for gbld's own releases build.sh is the way, not -a.
var version = "1.22.0"
var oses = []string{"darwin", "linux", "windows"} var oses = []string{"darwin", "linux", "windows"}
var arches = []string{"arm64", "amd64"} var arches = []string{"arm64", "amd64"}
@@ -49,7 +50,6 @@ var r3 = regexp.MustCompile(`\.go$`) // go sour
var r4 = regexp.MustCompile(`(?m)^(\s*var\s+build\s*=\s*")([^"]*)(")`) // build counter var r4 = regexp.MustCompile(`(?m)^(\s*var\s+build\s*=\s*")([^"]*)(")`) // build counter
var r5 = regexp.MustCompile(`(?im)^\s*var\s+version\s*=\s*"([^"]*)"`) // target version var r5 = regexp.MustCompile(`(?im)^\s*var\s+version\s*=\s*"([^"]*)"`) // target version
var r6 = regexp.MustCompile(`(?s)import\s*\((.*?)\)`) // import block var r6 = regexp.MustCompile(`(?s)import\s*\((.*?)\)`) // import block
var r7 = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) // safe build name
type opts struct { // ------------------------------------------------------------------------- command line options type opts struct { // ------------------------------------------------------------------------- command line options
buildonly bool buildonly bool
@@ -80,7 +80,11 @@ func main() { // ===============================================================
opt_a := flag.Bool("a", false, "") opt_a := flag.Bool("a", false, "")
opt_v := flag.Bool("v", false, "") opt_v := flag.Bool("v", false, "")
opt_h := flag.Bool("h", false, "") opt_h := flag.Bool("h", false, "")
opt_u := flag.Bool("u", false, "")
opt_update := flag.Bool("update", false, "") // selfupdate.go
opt_checkupdate := flag.Bool("check-update", false, "")
opt_refresh := flag.Bool(strings.TrimPrefix(updateRefreshFlag, "--"), false, "") // background run, not in the
// help, name tied to the const
flag.Usage = func() { flag.Usage = func() {
P() P()
@@ -88,7 +92,7 @@ func main() { // ===============================================================
P() P()
P(Cw("Usage:")) P(Cw("Usage:"))
P(Cy(" gbld [-b] [-1] [-i] [-x] [-c <CMD>] [-o <OPTIONS>] [build name]")) P(Cy(" gbld [-b] [-1] [-i] [-x] [-c <CMD>] [-o <OPTIONS>] [build name]"))
P(Cy(" gbld [-a] [-u]")) P(Cy(" gbld [-a]"))
P(Cw(" -b build only")) P(Cw(" -b build only"))
P(Cw(" -1 run only once, no watching (exit code: build error or exit code of the build)")) P(Cw(" -1 run only once, no watching (exit code: build error or exit code of the build)"))
P(Cw(" -i run goimports before build")) P(Cw(" -i run goimports before build"))
@@ -98,7 +102,8 @@ func main() { // ===============================================================
P(Cw(" -v show version")) P(Cw(" -v show version"))
P(Cw(" -h show help")) P(Cw(" -h show help"))
P(Cw(" -a build for all platforms")) P(Cw(" -a build for all platforms"))
P(Cw(" -u upload builds to " + UPLOADHOST)) P(Cw(" --check-update look for a newer release"))
P(Cw(" --update download and install the newest release"))
P() P()
P(Cw(" · if no build name is supplied the current directory name will be used")) P(Cw(" · if no build name is supplied the current directory name will be used"))
P() P()
@@ -120,18 +125,14 @@ func main() { // ===============================================================
o := opts{buildonly: *opt_b, once: *opt_1, imports: *opt_i, exitafter: *opt_x, o := opts{buildonly: *opt_b, once: *opt_1, imports: *opt_i, exitafter: *opt_x,
postcmd: *opt_c, runargs: *opt_o} postcmd: *opt_c, runargs: *opt_o}
if *opt_v { // ·············································································· show version // These run before the toolchain is looked for: none of them needs 'go' or // no go,
info() // 'goimports', and selfupdate.go probes a fresh download with 'gbld -v', // no goimports
os.Exit(0) // which therefore has to work on a machine that has neither.
} if (*opt_refresh) { selfUpdate.refresh(); return // update options
} else if (*opt_update) { runupdate(selfUpdate.install); return
if *opt_h { // ····················································································· show help } else if (*opt_checkupdate) { runupdate(selfUpdate.check); return
flag.Usage() } else if (*opt_v) { info(); return
os.Exit(0) } else if (*opt_h) { flag.Usage(); return
}
if os.Getenv("GBLD_NO_UPDATE") == "" { // ······································· check for a newer gbld version
checkforupdate(UPDATEURL)
} }
GO = lookup("go") // ······················································· check for the required toolchain GO = lookup("go") // ······················································· check for the required toolchain
@@ -157,14 +158,6 @@ func main() { // ===============================================================
if *opt_a { // ······························································· build for other platforms if *opt_a { // ······························································· build for other platforms
info() info()
buildall(name) buildall(name)
}
if *opt_u { // ······································································ upload builds to gozilla
info()
uploadall(name)
}
if *opt_a || *opt_u {
os.Exit(0) os.Exit(0)
} }
@@ -274,6 +267,14 @@ func buildAndRun(name, file string, o opts) bool { // -------------------------
PN("\033[2J\033[1;1H") PN("\033[2J\033[1;1H")
} }
info() info()
// Costs nothing: the hint comes from the note in the cache, the asking
// happens once a day at most and in the background. It stands after the
// screen is cleared, or every rebuild would wipe it away again.
if hint := selfUpdate.daily(); hint != "" {
P(Cy(hint))
}
P(Cc("--- start compiling " + file + " ---")) P(Cc("--- start compiling " + file + " ---"))
stopRunning() stopRunning()
@@ -478,58 +479,11 @@ func buildall(name string) { // ------------------------------------------------
PO("all builds done", targetversion) PO("all builds done", targetversion)
} }
func uploadall(name string) { // ------------------------------------------------------- upload builds to gozilla func runupdate(task func(io.Writer) error) { // ----------------------------------- run a task from selfupdate.go
if !r7.MatchString(name) { // the name ends up in a remote shell command if err := task(os.Stdout); err != nil {
PE("invalid build name", name)
os.Exit(1)
}
targetversion, err := gettargetversion()
if err != nil {
PE(err.Error()) PE(err.Error())
os.Exit(1) os.Exit(1)
} }
files := []string{}
for _, osys := range oses {
for _, arch := range arches {
ofile := filepath.Join("bin", artifact(name, targetversion, osys, arch))
if fileExists(ofile) {
files = append(files, ofile)
}
}
}
if len(files) == 0 {
PE("no builds found for version "+targetversion, "run 'gbld -a' first")
os.Exit(1)
}
for _, f := range []string{"bin/checksums.txt", "bin/version.txt"} {
if !fileExists(f) {
PE("missing "+f, "run 'gbld -a' first")
os.Exit(1)
}
files = append(files, f)
}
P(Ccb("upload builds to "+UPLOADHOST), Cwb(targetversion))
dst := UPLOADPATH + "/" + name
if err := run("ssh", UPLOADHOST, SF("mkdir -p '%s' && rm -f '%s'/*_*_*", dst, dst)); err != nil {
os.Exit(1)
}
if err := run(append([]string{"scp"}, append(files, UPLOADHOST+":"+dst)...)...); err != nil {
os.Exit(1)
}
PO("upload done", targetversion)
}
func run(args ...string) error { // ------------------------------------------------- run a command, report failure
P(Cy(strings.Join(args, " ")))
out, err := exec.Command(args[0], args[1:]...).CombinedOutput()
if err != nil {
PE(args[0]+" failed", strings.TrimSpace(string(out)))
}
return err
} }
func artifact(name, version, osys, arch string) string { // ------------------------------ name of a build artifact func artifact(name, version, osys, arch string) string { // ------------------------------ name of a build artifact
+89 -25
View File
@@ -2,6 +2,9 @@
package main package main
import ( import (
"context"
"crypto/sha256"
"encoding/hex"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -142,65 +145,126 @@ func TestFileExists(t *testing.T) { // --------------------------------- must no
} }
} }
func TestGetchecksum(t *testing.T) { // ------------------------------------- checksums are read and validated func TestUpdateFindSum(t *testing.T) { // ----------------------------------- checksums are read and validated
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { good := strings.Repeat("ab", 32) // 32 bytes, as a sha256 has
if r.URL.Path != "/checksums.txt" { body := []byte(good + " gbld-linux-amd64\n" +
http.NotFound(w, r) good + " *gbld-windows-amd64.exe\n" + // binary mode marks the name with '*'
return "zzzz broken_sum\n")
}
w.Write([]byte("aabb gbld_1.0.0_linux_amd64\n" +
"0f0f gbld_1.0.0_windows_amd64.exe\n" +
"zzzz broken_sum\n"))
}))
defer srv.Close()
client := &http.Client{Timeout: 5 * time.Second} sum, err := updateFindSum(body, "gbld-linux-amd64")
sum, err := getchecksum(client, srv.URL+"/checksums.txt", "gbld_1.0.0_linux_amd64")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(sum) != 2 || sum[0] != 0xaa || sum[1] != 0xbb { if hex.EncodeToString(sum) != good {
t.Errorf("wrong checksum decoded: %x", sum) t.Errorf("wrong checksum decoded: %x", sum)
} }
if _, err := getchecksum(client, srv.URL+"/checksums.txt", "gbld_1.0.0_darwin_arm64"); err == nil { if _, err := updateFindSum(body, "gbld-windows-amd64.exe"); err != nil {
t.Errorf("the '*' of binary mode belongs to the format, not to the name: %v", err)
}
if _, err := updateFindSum(body, "gbld-darwin-arm64"); err == nil {
t.Error("a missing entry must be an error - never update without a checksum") t.Error("a missing entry must be an error - never update without a checksum")
} }
if _, err := getchecksum(client, srv.URL+"/checksums.txt", "broken_sum"); err == nil { if _, err := updateFindSum(body, "broken_sum"); err == nil {
t.Error("a malformed checksum must be an error") t.Error("a malformed checksum must be an error")
} }
if _, err := getchecksum(client, srv.URL+"/missing.txt", "gbld_1.0.0_linux_amd64"); err == nil { if _, err := updateFindSum([]byte(strings.Repeat("ab", 16)+" gbld-linux-amd64\n"), "gbld-linux-amd64"); err == nil {
t.Error("a missing checksums.txt must be an error") t.Error("a hash of the wrong length must be an error")
} }
} }
func TestDoupdateRejectsWrongChecksum(t *testing.T) { // ------------- a manipulated download is never applied func TestUpdateChecksum(t *testing.T) { // ------------------------- the checksum comes out of the release itself
want := strings.Repeat("cd", 32)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("this is not the binary you asked for")) w.Write([]byte(want + " gbld-linux-amd64\n"))
})) }))
defer srv.Close() defer srv.Close()
err := doupdate(&http.Client{Timeout: 5 * time.Second}, srv.URL+"/gbld", []byte{0xde, 0xad, 0xbe, 0xef}) rel := updateRelease{TagName: "v1.2.3", Assets: []updateAsset{
{Name: "gbld-linux-amd64", URL: srv.URL + "/gbld-linux-amd64"},
{Name: updateChecksums, URL: srv.URL + "/" + updateChecksums},
}}
sum, err := selfUpdate.checksum(rel, "gbld-linux-amd64")
if err != nil {
t.Fatal(err)
}
if hex.EncodeToString(sum) != want {
t.Errorf("wrong checksum: %x", sum)
}
bare := updateRelease{TagName: "v1.2.3", Assets: rel.Assets[:1]} // no checksums.txt published
if _, err := selfUpdate.checksum(bare, "gbld-linux-amd64"); err == nil {
t.Error("a release without checksums must be refused")
}
}
func TestUpdateDownloadRejectsWrongChecksum(t *testing.T) { // ------ a manipulated download is never installed
body := []byte("this is not the binary you asked for")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(body)
}))
defer srv.Close()
dir := t.TempDir()
exe := filepath.Join(dir, "gbld")
a := &updateAsset{Name: "gbld-linux-amd64", Size: int64(len(body)), URL: srv.URL + "/gbld-linux-amd64"}
_, err := selfUpdate.download(a, exe, 0755, []byte{0xde, 0xad, 0xbe, 0xef})
if err == nil { if err == nil {
t.Fatal("update with a wrong checksum was accepted") t.Fatal("a download with a wrong checksum was accepted")
} }
if !strings.Contains(strings.ToLower(err.Error()), "checksum") { if !strings.Contains(strings.ToLower(err.Error()), "checksum") {
t.Errorf("expected a checksum error, got: %v", err) t.Errorf("expected a checksum error, got: %v", err)
} }
left, _ := filepath.Glob(filepath.Join(dir, "*"))
if len(left) != 0 {
t.Errorf("the rejected download was left behind: %v", left)
}
sum := sha256.Sum256(body) // the same bytes, now with the checksum that fits
tmp, err := selfUpdate.download(a, exe, 0755, sum[:])
if err != nil {
t.Fatalf("a matching checksum was refused: %v", err)
}
os.Remove(tmp)
} }
func TestFetchStatus(t *testing.T) { // -------------------------------------- http errors must not be swallowed func TestUpdateGetStatus(t *testing.T) { // ---------------------------------- http errors must not be swallowed
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nope", http.StatusInternalServerError) http.Error(w, "nope", http.StatusInternalServerError)
})) }))
defer srv.Close() defer srv.Close()
if _, err := fetch(&http.Client{Timeout: 5 * time.Second}, srv.URL, 1<<20); err == nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := updateGet(ctx, srv.URL); err == nil {
t.Error("http 500 was reported as success") t.Error("http 500 was reported as success")
} }
} }
func TestUpdateCompare(t *testing.T) { // ------------------- versions are numbers, not strings: 1.21.10 > 1.21.9
for _, c := range []struct {
a, b string
want int
}{
{"1.21.10", "1.21.9", 1},
{"v1.21.0", "1.21.0", 0},
{"1.21", "1.21.0", 0},
{"1.21.0-rc1", "1.21.0", -1}, // not finished yet
{"1.9.0", "1.10.0", -1},
} {
if got := updateCompare(c.a, c.b); got != c.want {
t.Errorf("updateCompare(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
}
if got := updateCompare(c.b, c.a); got != -c.want {
t.Errorf("updateCompare(%q,%q) = %d, want %d", c.b, c.a, got, -c.want)
}
}
}
func TestToolbox(t *testing.T) { // ------------------------------------------------ toolbox edge cases func TestToolbox(t *testing.T) { // ------------------------------------------------ toolbox edge cases
if got := Shortstr("abcdef", 4); got != "ab.." { if got := Shortstr("abcdef", 4); got != "ab.." {
t.Errorf("Shortstr: got %q", got) t.Errorf("Shortstr: got %q", got)
-4
View File
@@ -4,18 +4,15 @@ go 1.26.1
require ( require (
github.com/AlecAivazis/survey/v2 v2.3.7 github.com/AlecAivazis/survey/v2 v2.3.7
github.com/Masterminds/semver/v3 v3.5.0
github.com/eknkc/basex v1.0.1 github.com/eknkc/basex v1.0.1
github.com/fatih/color v1.19.0 github.com/fatih/color v1.19.0
github.com/fsnotify/fsnotify v1.10.1 github.com/fsnotify/fsnotify v1.10.1
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
github.com/minio/selfupdate v0.6.0
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
github.com/tidwall/gjson v1.19.0 github.com/tidwall/gjson v1.19.0
) )
require ( require (
aead.dev/minisign v0.3.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
@@ -29,7 +26,6 @@ require (
github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.40.0 // indirect golang.org/x/text v0.40.0 // indirect
-18
View File
@@ -1,10 +1,5 @@
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
aead.dev/minisign v0.3.0 h1:8Xafzy5PEVZqYDNP60yJHARlW1eOQtsKNp/Ph2c0vRA=
aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
@@ -41,8 +36,6 @@ github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -75,38 +68,27 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
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/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
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-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
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=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+585
View File
@@ -0,0 +1,585 @@
// selfupdate.go — updating oneself from the releases of a Gitea instance.
//
// The file is meant to be copied: take it into another program, adjust the
// configuration block below, hang `--update` and `--check-update` into the
// options — done. It needs nothing but the standard library, and apart from
// that block it brings no names that do not begin with "selfUpdate" or
// "update".
//
// It assumes the layout build.sh produces: one release per version, whose tag
// is the bare number (1.21.4, a leading "v" is allowed), holding one asset
// "<name>-<goos>-<goarch>" each — that is, exactly the files from ./bin. Under
// /api/v1/repos/<owner>/<repo>/releases/latest Gitea hands out the newest
// release that is neither a draft nor a prerelease; GitHub speaks the same
// route with different field names and is therefore not covered.
//
// Two things this copy does differently from the one in dns, both deliberate:
//
// - The release has to publish a "checksums.txt" — the file build.sh writes,
// in the format of `shasum -a 256`. What is downloaded is weighed against
// the sha256 named there for exactly this asset, and no checksum means no
// update. gbld had that property before it fetched from a gitea, and it
// does not give it up.
// - The TLS certificate is verified, as it is everywhere else. dns turns
// that off for machines with an outdated trust store; if you copy this
// file to such a machine, that is the line to revisit — knowing that
// anyone in the network path can then hand out both the binary and the
// checksum that matches it.
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"hash"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
// ------------------------------------------------------------ Configuration
var selfUpdate = selfUpdater{
repo: "https://git.fhi.mpg.de/mike/gbld",
asset: "gbld",
current: version, // from gbld.go, set by -ldflags
verify: []string{"-v"},
every: 24 * time.Hour,
quietEnv: "GBLD_NO_UPDATE",
}
// updateChecksums is the asset holding the sha256 of every other asset, in the
// format of `shasum -a 256`. A release without it is a release this program
// refuses to install: what cannot be weighed is not put in place.
const updateChecksums = "checksums.txt"
type selfUpdater struct {
repo string // repo URL as in the browser: https://host/owner/repo
asset string // base name of the assets, "-<goos>-<goarch>" is added
current string // the running version
verify []string // trial run of the download; empty skips it
every time.Duration // how often to look on its own; 0 turns that off
quietEnv string // this environment variable set: keep quiet as well
}
// updateRefreshFlag is the option the program calls itself with, in the
// background. It is deliberately absent from the help.
const updateRefreshFlag = "--update-refresh"
// ------------------------------------------------------------ Looking by itself
// daily is the hook for the ordinary run of the program. It costs nothing: in
// the foreground the network is never touched. What comes back is the line
// pointing at a new version — or "", when there is nothing to say; what it
// looks like is up to the caller. Should the note be older than `every`, daily
// starts a background run on the side, whose answer the next call will find
// waiting.
func (u selfUpdater) daily() string {
if u.every <= 0 || os.Getenv(u.quietEnv) != "" || !updateOnTerminal() {
return ""
}
st := u.loadState() // no file: the zero value, hence due at once
if time.Since(st.Checked) >= u.every {
// The timestamp moves on before the asking, not after: otherwise two
// simultaneous runs start two queries, and a server that is not in the
// mood would get a new one on every call. If the note does not stay
// put, nothing is asked either — else an unwritable cache directory
// would mean one process per call.
st.Checked = time.Now()
if u.saveState(st) == nil {
u.spawnRefresh()
}
}
if st.Latest == "" || updateCompare(st.Latest, u.current) <= 0 {
return ""
}
return fmt.Sprintf("%s %s is available, run '%s --update'", u.asset, st.Latest, u.asset)
}
// refresh is the background run: ask, write it down, stay quiet. The writing
// down is done by latest; if the query fails, the old state remains.
func (u selfUpdater) refresh() {
_, _ = u.latest()
}
// spawnRefresh calls this program once more, only to ask, and does not wait.
// Without a Wait the child is adopted by init when this process ends — it thus
// outlives the call, and the call's output stays untouched by it.
func (u selfUpdater) spawnRefresh() {
exe, err := os.Executable()
if err != nil {
return
}
cmd := exec.Command(exe, updateRefreshFlag)
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil // everything to /dev/null
if cmd.Start() == nil {
cmd.Process.Release()
}
}
// The hint is meant for the person sitting there. Running in a pipe, in a
// script or under cron, the program neither asks nor says anything.
func updateOnTerminal() bool {
st, err := os.Stderr.Stat()
return err == nil && st.Mode()&os.ModeCharDevice != 0
}
// --------------------------------------------------------------------- Note
// updateState is what is left between two calls: when the last question was
// asked and what came of it.
type updateState struct {
Checked time.Time `json:"checked"`
Latest string `json:"latest"`
}
// The note lives in the cache directory, not in the configuration: if it gets
// lost, the only cost is asking once too early.
func (u selfUpdater) statePath() (string, error) {
dir, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(dir, u.asset, "update.json"), nil
}
func (u selfUpdater) loadState() updateState {
var st updateState
path, err := u.statePath()
if err != nil {
return st
}
b, err := os.ReadFile(path)
if err != nil {
return st
}
json.Unmarshal(b, &st) // a broken file counts as none
return st
}
func (u selfUpdater) saveState(st updateState) error {
path, err := u.statePath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
b, err := json.Marshal(st)
if err != nil {
return err
}
// By way of a file alongside, so that a simultaneous run never comes upon
// half a JSON.
tmp := path + ".new"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return err
}
return nil
}
// ------------------------------------------------------------------ The work
// check only looks and touches nothing.
func (u selfUpdater) check(w io.Writer) error {
rel, err := u.latest()
if err != nil {
return err
}
if updateCompare(rel.TagName, u.current) <= 0 {
fmt.Fprintf(w, "%s %s is up to date\n", u.asset, u.current)
return nil
}
fmt.Fprintf(w, "%s %s is available, running %s\n %s\n run '%s --update' to install it\n",
u.asset, rel.TagName, u.current, rel.HTMLURL, u.asset)
return nil
}
// install fetches the newest release and replaces the running file with it.
func (u selfUpdater) install(w io.Writer) error {
rel, err := u.latest()
if err != nil {
return err
}
if updateCompare(rel.TagName, u.current) <= 0 {
fmt.Fprintf(w, "%s %s is up to date\n", u.asset, u.current)
return nil
}
want := u.assetName()
src := rel.asset(want)
if src == nil {
names := make([]string, len(rel.Assets))
for i, a := range rel.Assets {
names[i] = a.Name
}
return fmt.Errorf("release %s has no %q (only %s)", rel.TagName, want, strings.Join(names, ", "))
}
// Before a byte is fetched: without a checksum to weigh it against there is
// nothing to install, and saying so now saves the download.
sum, err := u.checksum(rel, want)
if err != nil {
return err
}
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("cannot locate the running binary: %w", err)
}
// An installed gbld is often a symlink into ./bin. What should be replaced is
// the file behind it, not the link.
if real, err := filepath.EvalSymlinks(exe); err == nil {
exe = real
}
mode := os.FileMode(0o755)
if st, err := os.Stat(exe); err == nil {
mode = st.Mode().Perm()
}
fmt.Fprintf(w, "downloading %s %s (%s)\n", want, rel.TagName, updateSize(src.Size))
tmp, err := u.download(src, exe, mode, sum)
if err != nil {
return err
}
defer os.Remove(tmp) // only bites when the renaming below falls through
if err := u.probe(tmp, rel.TagName); err != nil {
return err
}
if err := updateReplace(tmp, exe); err != nil {
return err
}
fmt.Fprintf(w, "%s %s → %s, at %s\n", u.asset, u.current, rel.TagName, exe)
return nil
}
// assetName is what this build of this program is called in a release. Windows
// carries the suffix its loader insists on; everywhere else the bare name.
func (u selfUpdater) assetName() string {
name := fmt.Sprintf("%s-%s-%s", u.asset, runtime.GOOS, runtime.GOARCH)
if runtime.GOOS == "windows" {
name += ".exe"
}
return name
}
// checksum fetches the release's checksums.txt and picks out the line for this
// asset. Every way out of here that is not a sha256 is an error: a release
// without checksums, one that does not mention this asset, a line that is not
// a hash — none of them ends in an installed binary.
func (u selfUpdater) checksum(rel updateRelease, name string) ([]byte, error) {
src := rel.asset(updateChecksums)
if src == nil {
return nil, fmt.Errorf("release %s publishes no %s, refusing to install %s unchecked",
rel.TagName, updateChecksums, name)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
resp, err := updateGet(ctx, src.URL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // a list of hashes, not a payload
if err != nil {
return nil, fmt.Errorf("cannot read %s of release %s: %w", updateChecksums, rel.TagName, err)
}
return updateFindSum(body, name)
}
// updateFindSum reads the format of `shasum -a 256`: hash, blanks, name. The
// asterisk some tools put in front of the name in binary mode is not part of it.
func updateFindSum(body []byte, name string) ([]byte, error) {
for _, line := range strings.Split(string(body), "\n") {
f := strings.Fields(line)
if len(f) != 2 || strings.TrimPrefix(f[1], "*") != name {
continue
}
sum, err := hex.DecodeString(f[0])
if err != nil || len(sum) != sha256.Size {
return nil, fmt.Errorf("%s holds no usable sha256 for %s", updateChecksums, name)
}
return sum, nil
}
return nil, fmt.Errorf("%s names no checksum for %s", updateChecksums, name)
}
func (u selfUpdater) download(a *updateAsset, exe string, mode os.FileMode, sum []byte) (string, error) {
// The new file comes into being next to the old one: same filesystem, so
// the renaming at the end is one atomic step and not half a copy. It also
// comes into being before the first byte — a missing write permission ought
// to show up before a few megabytes have gone down the wire.
dir := filepath.Dir(exe)
f, err := os.CreateTemp(dir, "."+filepath.Base(exe)+".new")
if err != nil {
var pe *os.PathError // the path is in the message already
if errors.As(err, &pe) {
err = pe.Err
}
return "", fmt.Errorf("cannot write to %s: %w", dir, err)
}
tmp := f.Name()
resp, err := updateGet(context.Background(), a.URL)
if err != nil {
f.Close()
os.Remove(tmp)
return "", err
}
defer resp.Body.Close()
// The hash grows while the file is written: the bytes are weighed on their
// way past, not read back afterwards.
var h hash.Hash = sha256.New()
n, err := io.Copy(io.MultiWriter(f, h), resp.Body)
if cerr := f.Close(); err == nil {
err = cerr
}
if err == nil && a.Size > 0 && n != a.Size {
err = fmt.Errorf("got %d of %d bytes from %s", n, a.Size, a.URL)
}
if err == nil && !bytes.Equal(h.Sum(nil), sum) {
err = fmt.Errorf("%s does not match its published checksum: got %s, expected %s",
a.Name, hex.EncodeToString(h.Sum(nil)), hex.EncodeToString(sum))
}
if err == nil {
err = os.Chmod(tmp, mode)
}
if err != nil {
os.Remove(tmp)
return "", err
}
return tmp, nil
}
// probe calls the freshly fetched runner once. That catches a file that is
// truncated, built for the wrong platform, or not executable in the first
// place, before it replaces the running one.
func (u selfUpdater) probe(path, tag string) error {
if len(u.verify) == 0 {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, path, u.verify...).CombinedOutput()
if err != nil {
return fmt.Errorf("the downloaded binary does not run: %w", err)
}
if !strings.Contains(string(out), strings.TrimPrefix(tag, "v")) {
return fmt.Errorf("the downloaded binary reports %q, expected %s",
strings.TrimSpace(string(out)), tag)
}
return nil
}
// updateReplace swaps the running file for the new one.
func updateReplace(tmp, exe string) error {
if err := os.Rename(tmp, exe); err == nil {
return nil
}
// Unix overwrites the file of a running program without complaint, Windows
// does not: there the old one has to be got out of the way first. Deleting
// it becomes possible when this process ends at the earliest — so the
// tidying up is allowed to fail.
old := exe + ".old"
os.Remove(old)
if err := os.Rename(exe, old); err != nil {
return fmt.Errorf("cannot replace %s: %w", exe, err)
}
if err := os.Rename(tmp, exe); err != nil {
os.Rename(old, exe) // back to how it was
return fmt.Errorf("cannot replace %s: %w", exe, err)
}
os.Remove(old)
return nil
}
// -------------------------------------------------------------------- Gitea
type updateRelease struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Assets []updateAsset `json:"assets"`
}
type updateAsset struct {
Name string `json:"name"`
Size int64 `json:"size"`
URL string `json:"browser_download_url"`
}
// asset picks one file out of a release by name, nil when it is not there.
func (r updateRelease) asset(name string) *updateAsset {
for i := range r.Assets {
if r.Assets[i].Name == name {
return &r.Assets[i]
}
}
return nil
}
func (u selfUpdater) latest() (updateRelease, error) {
base, err := u.apiBase()
if err != nil {
return updateRelease{}, err
}
// The question is a small one; if it hangs, it does not hang for long. The
// generous time limit of updateClient is meant for the download.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
resp, err := updateGet(ctx, base+"/releases/latest")
if err != nil {
return updateRelease{}, err
}
defer resp.Body.Close()
var rel updateRelease
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return updateRelease{}, fmt.Errorf("unexpected answer from %s: %w", base, err)
}
if rel.TagName == "" {
return updateRelease{}, fmt.Errorf("%s has no releases", u.repo)
}
// Every question that succeeds fills the note — no matter whether it came
// from --update, from --check-update or from the background run.
u.saveState(updateState{Checked: time.Now(), Latest: rel.TagName})
return rel, nil
}
// apiBase turns https://host/owner/repo into the API root of the repo.
func (u selfUpdater) apiBase() (string, error) {
bad := fmt.Errorf("repo %q: expected https://host/owner/repo", u.repo)
ref, err := url.Parse(strings.TrimSuffix(strings.TrimSuffix(u.repo, "/"), ".git"))
if err != nil || ref.Host == "" {
return "", bad
}
parts := strings.Split(strings.Trim(ref.Path, "/"), "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", bad
}
return fmt.Sprintf("%s://%s/api/v1/repos/%s/%s", ref.Scheme, ref.Host, parts[0], parts[1]), nil
}
// One time limit for all of it: the look costs a few hundred milliseconds, the
// download a few megabytes — both may hang, but not forever.
//
// Nothing is turned off here: the certificate of the gitea is verified like any
// other. Together with the checksum from the release, that is what stands
// between this program and a binary someone else picked — the probe below is
// none of it, a hostile binary prints whatever version string is asked of it.
var updateClient = &http.Client{
Timeout: 5 * time.Minute,
}
func updateGet(ctx context.Context, target string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "selfupdate.go (+"+runtime.GOOS+"/"+runtime.GOARCH+")")
resp, err := updateClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("GET %s: %s", target, resp.Status)
}
return resp, nil
}
// ------------------------------------------------------------------ Numbers
// updateCompare compares two versions component by component, numerically, so
// that 2.1.10 lands behind 2.1.9 and not in front of it. A leading "v" does not
// count, missing places count as 0 (2.1 == 2.1.0), and a suffix on the number
// makes the version older, not newer (2.1.6-rc1 < 2.1.6). The result is the one
// of strings.Compare: -1, 0, 1.
func updateCompare(a, b string) int {
as := strings.Split(strings.TrimPrefix(a, "v"), ".")
bs := strings.Split(strings.TrimPrefix(b, "v"), ".")
for i := 0; i < len(as) || i < len(bs); i++ {
x, y := "0", "0"
if i < len(as) {
x = as[i]
}
if i < len(bs) {
y = bs[i]
}
if c := updateComparePart(x, y); c != 0 {
return c
}
}
return 0
}
func updateComparePart(a, b string) int {
na, ra := updateSplitNum(a)
nb, rb := updateSplitNum(b)
switch {
case na != nb:
if na < nb {
return -1
}
return 1
case ra == rb:
return 0
case ra == "": // 2.1.6 is finished, 2.1.6-rc1 is not yet
return 1
case rb == "":
return -1
}
return strings.Compare(ra, rb)
}
// updateSplitNum separates "10-rc1" into 10 and "-rc1".
func updateSplitNum(s string) (int, string) {
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
n, _ := strconv.Atoi(s[:i])
return n, s[i:]
}
// updateSize is deliberately a small formatting of its own and not a helper
// from the toolbox — the file is meant to stand on its own.
func updateSize(b int64) string {
const k = 1024
switch {
case b > k*k:
return fmt.Sprintf("%.1f MB", float64(b)/k/k)
case b > k:
return fmt.Sprintf("%.1f KB", float64(b)/k)
default:
return fmt.Sprintf("%d B", b)
}
}
+4 -73
View File
@@ -5,7 +5,6 @@ import (
"crypto/rand" "crypto/rand"
"database/sql" "database/sql"
"encoding/base64" "encoding/base64"
"encoding/hex"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@@ -15,96 +14,28 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2"
"github.com/AlecAivazis/survey/v2/core" "github.com/AlecAivazis/survey/v2/core"
"github.com/AlecAivazis/survey/v2/terminal" "github.com/AlecAivazis/survey/v2/terminal"
"github.com/Masterminds/semver/v3"
"github.com/eknkc/basex" "github.com/eknkc/basex"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/mgutz/ansi" "github.com/mgutz/ansi"
"github.com/minio/selfupdate"
"github.com/spf13/viper" "github.com/spf13/viper"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
) )
var tbversion = "0.7.0" var tbversion = "0.7.0"
var HTTPTIMEOUT = 3 * time.Second // version.txt / checksums.txt - must never stall a build
var DOWNLOADTIMEOUT = 10 * time.Minute // the binary itself, http.Client.Timeout covers the whole body
var CHRS = "VW9IdGJ6eXh1T25DRHdrc2M5MlhOQVNQcEJFWnJhWVY2ZEowaFJLdmoxNUdxVDRJZkZpTTdRZW0zTFc4Z2w=" var CHRS = "VW9IdGJ6eXh1T25DRHdrc2M5MlhOQVNQcEJFWnJhWVY2ZEowaFJLdmoxNUdxVDRJZkZpTTdRZW0zTFc4Z2w="
var LR = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") var LR = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func checkforupdate(URL string) { // ----------------------------------------------------- check for new version // Updating oneself lives in selfupdate.go — one file per program, configured at
prg := prgname() // its head, driven by the --update and --check-update options. It fetches from
client := &http.Client{Timeout: HTTPTIMEOUT} // the releases of the gitea and weighs every download against the checksum
// published beside it.
body, err := fetch(client, URL+"/version.txt", 1<<20) // update server unreachable: never block the build
if err != nil { return }
lversion := strings.TrimSpace(string(body)) // exactly as published - it names the artifact
sv_version, err := semver.NewVersion(version)
if err != nil { return }
sv_lversion, err := semver.NewVersion(lversion)
if err != nil { return }
if !sv_lversion.GreaterThan(sv_version) { return }
if !Yesno(SF("new '%s' version found (%s -> %s), update now?",prg,sv_version,lversion),true,false) {
return
}
target := SF("%s_%s_%s_%s",prg,lversion,runtime.GOOS,runtime.GOARCH)
if runtime.GOOS == "windows" { target += ".exe" }
sum, err := getchecksum(client, URL+"/checksums.txt", target) // no checksum -> no update
if err != nil {
PE("update aborted", err.Error())
os.Exit(1)
}
if err := doupdate(&http.Client{Timeout: DOWNLOADTIMEOUT}, URL+"/"+target, sum); err != nil {
PE("update failed", err.Error())
os.Exit(1)
}
PO("update successful","please run your last command again")
os.Exit(0)
}
func doupdate(client *http.Client, url string, checksum []byte) error { // --------------------------- do update
resp, err := client.Get(url)
if err != nil { return err }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { return SE("server returned status: %v", resp.Status) }
return selfupdate.Apply(resp.Body, selfupdate.Options{Checksum: checksum})
}
func getchecksum(client *http.Client, url string, name string) ([]byte, error) { // -- sha256 of a published file
body, err := fetch(client, url, 1<<20)
if err != nil { return nil, SE("no checksums published (%v)", err) }
for _, line := range strings.Split(string(body), "\n") {
f := strings.Fields(line)
if len(f) == 2 && f[1] == name {
sum, err := hex.DecodeString(f[0])
if err != nil { return nil, SE("bad checksum for %s", name) }
return sum, nil
}
}
return nil, SE("no checksum for %s", name)
}
func fetch(client *http.Client, url string, max int64) ([]byte, error) { // ------------------- fetch a small file
resp, err := client.Get(url)
if err != nil { return nil, err }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { return nil, SE("server returned status: %v", resp.Status) }
return io.ReadAll(io.LimitReader(resp.Body, max))
}
func checkaccess(NETS []string) { // ------------------------------------------------- check ip net based access func checkaccess(NETS []string) { // ------------------------------------------------- check ip net based access
match:=0; match:=0;
+1
View File
@@ -0,0 +1 @@
1.22.0