From 55418a10407c1cd37cf9b347bd7e0be8f5adbf35 Mon Sep 17 00:00:00 2001 From: Michael Wesemann Date: Wed, 12 Aug 2026 16:23:52 +0200 Subject: [PATCH] [mike@mwxm4] --- README.md | 49 +++++ build.sh | 90 ++++++--- build_releases.sh | 30 --- go.mod | 19 +- go.sum | 71 +------ goca.go | 56 +++++- notarize.sh | 26 +-- selfupdate.go | 493 ++++++++++++++++++++++++++++++++++++++++++++++ update.go | 127 ------------ version.go | 8 +- version.txt | 1 + 11 files changed, 679 insertions(+), 291 deletions(-) delete mode 100755 build_releases.sh create mode 100644 selfupdate.go delete mode 100644 update.go create mode 100644 version.txt diff --git a/README.md b/README.md index bf458a1..5fa24be 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,55 @@ The interactive shell supports the following commands: --- +## 🔄 Updates + +goca updates itself from the releases of the Gitea repository at +[git.fhi.mpg.de/mike/goca](https://git.fhi.mpg.de/mike/goca): + +```bash +goca --version # print the running version +goca --check-update # only look for a newer release +goca --update # download and install the newest release +``` + +`--update` fetches the asset `goca--` of the newest release, runs +the downloaded binary once with `--version` to make sure it works, and only then +replaces the running file — following a symlink to the real file behind it. + +Besides that, goca looks for a new release **once a day, in the background**: the +foreground run never touches the network for it, it only reads the note in +`~/.cache/goca/update.json` (on macOS `~/Library/Caches/goca/update.json`) left +by the last look and puts a dimmed line on stderr when something newer is out. +`GOCA_NO_UPDATE_CHECK=1` or the `-y` flag turns that off, and without a terminal +(pipe, script, cron) goca stays quiet anyway. + +### Building and publishing a release + +`build.sh` builds every platform into `./bin`, each carrying the same version: + +```bash +./build.sh # 1.1.2 -> 1.1.3, all platforms +PLATFORMS="linux/amd64" ./build.sh # just one +VERSION=1.2.0 ./build.sh # a minor or major step, named outright +``` + +`version.txt` holds the version just built and is stepped by 0.0.1 on every run; +the number goes into the binaries via `-ldflags`, so the literal in `version.go` +is only what a bare `go build` picks up. The host platform additionally gets the +symlink `bin/goca`. + +For macOS, sign and notarize the two Darwin binaries in place before uploading: + +```bash +./notarize.sh # needs APPLE_ID, APPLE_PASSWORD, TEAM_ID +``` + +Then create a release at git.fhi.mpg.de whose tag is the bare version from +`version.txt` (e.g. `1.1.3`) and attach the files from `bin/` to it. The asset +names are what `--update` looks for, so they must stay as `build.sh` writes them. + +--- + ## 📦 Project Architecture * [cli.go](file:///Users/mike/src/goca/cli.go) - Command loop, line-completion, history, and user input handler. diff --git a/build.sh b/build.sh index b5d7e3c..80797e6 100755 --- a/build.sh +++ b/build.sh @@ -1,40 +1,70 @@ -#!/bin/bash +#!/bin/sh +# Build goca 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. The literal in +# version.go is only ever what a bare `go build` picks up. +# +# The names in ./bin are what selfupdate.go looks for in a release: +# "goca--", with .exe on Windows. Upload exactly these files to a +# Gitea release whose tag is the bare version number, and --update finds them. +# +# 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.2.0 ./build.sh +# +# -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 - -# Make sure we are in the script's directory cd "$(dirname "$0")" -# Read version from version.go -VERSION_LINE=$(grep "var Version =" version.go) -CURRENT_VERSION=$(echo "$VERSION_LINE" | sed -E 's/.*"([^"]+)".*/\1/') +PLATFORMS=${PLATFORMS:-"darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64"} -if [ -z "$CURRENT_VERSION" ]; then - echo "❌ Error: Could not parse version from version.go" - exit 1 +if [ -n "$VERSION" ]; then + NV="$VERSION" +else + V=$(cat version.txt 2>/dev/null || echo 1.1.2) + + # split MAJOR.MINOR.PATCH and increment PATCH (no carry: 1.1.9 -> 1.1.10) + MAJOR=${V%%.*} + REST=${V#*.} + MINOR=${REST%%.*} + PATCH=${REST#*.} + PATCH=$((PATCH + 1)) + NV="$MAJOR.$MINOR.$PATCH" fi -# Split version by dot -IFS='.' read -r -a VERSION_PARTS <<< "$CURRENT_VERSION" -MAJOR="${VERSION_PARTS[0]}" -MINOR="${VERSION_PARTS[1]}" -PATCH="${VERSION_PARTS[2]}" +mkdir -p bin +HOST="$(go env GOOS)/$(go env GOARCH)" -# Increment patch version -NEW_PATCH=$((PATCH + 1)) -NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH" +for p in $PLATFORMS; do + os=${p%/*} + arch=${p#*/} + ext="" + if [ "$os" = "windows" ]; then + ext=".exe" # Windows runs nothing without it, and --update knows that + fi + out="bin/goca-$os-$arch$ext" -echo "🚀 Incrementing version: $CURRENT_VERSION ➡️ $NEW_VERSION" + # CGO_ENABLED=0 throughout: it makes the cross builds work without a + # toolchain per target and the binaries static. goca only needs the network + # and the filesystem, 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 "$out" . -# Write back to version.go using perl for portable in-place edit -perl -pi -e "s/var Version = \"$CURRENT_VERSION\"/var Version = \"$NEW_VERSION\"/" version.go + if [ "$p" = "$HOST" ]; then + ln -sf "goca-$os-$arch$ext" bin/goca # the one for this machine + echo " $out -> bin/goca" + else + echo " $out" + fi +done -# Update README.md version output -if [ -f README.md ]; then - perl -pi -e "s/goca v$CURRENT_VERSION/goca v$NEW_VERSION/g" README.md -fi - -# Build the project -echo "🛠️ Building goca binary..." -go build -o goca - -echo "🎉 Build successful! version: v$NEW_VERSION" +echo "$NV" > version.txt +echo "built goca v$NV" diff --git a/build_releases.sh b/build_releases.sh deleted file mode 100755 index eb6dc50..0000000 --- a/build_releases.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/bash -set -e - -# Make sure we are in the script's directory -cd "$(dirname "$0")" - -# Read version from version.go -VERSION_LINE=$(grep "var Version =" version.go) -VERSION=$(echo "$VERSION_LINE" | sed -E 's/.*"([^"]+)".*/\1/') - -echo "🛠️ Compiling release binaries for version v$VERSION..." -mkdir -p bin - -# macOS Intel -echo "🍏 Building macOS Intel (amd64)..." -GOOS=darwin GOARCH=amd64 go build -o bin/goca-darwin-amd64 - -# macOS Apple Silicon -echo "🍏 Building macOS Apple Silicon (arm64)..." -GOOS=darwin GOARCH=arm64 go build -o bin/goca-darwin-arm64 - -# Linux Intel -echo "🐧 Building Linux Intel (amd64)..." -GOOS=linux GOARCH=amd64 go build -o bin/goca-linux-amd64 - -# Windows Intel -echo "🏁 Building Windows Intel (amd64)..." -GOOS=windows GOARCH=amd64 go build -o bin/goca-windows-amd64.exe - -echo "🎉 All release binaries built in bin/!" diff --git a/go.mod b/go.mod index be18d78..4f62b96 100644 --- a/go.mod +++ b/go.mod @@ -3,19 +3,8 @@ module goca go 1.26.1 require ( - aead.dev/minisign v0.2.0 // indirect - github.com/AlecAivazis/survey/v2 v2.3.7 // indirect - github.com/Masterminds/semver/v3 v3.5.0 // indirect - github.com/chzyer/readline v1.5.1 // indirect - github.com/fatih/color v1.19.0 // indirect - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect - github.com/minio/selfupdate v0.6.0 // indirect - github.com/shopspring/decimal v1.4.0 // indirect - golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect - golang.org/x/text v0.4.0 // indirect + github.com/chzyer/readline v1.5.1 + github.com/shopspring/decimal v1.4.0 ) + +require golang.org/x/sys v0.42.0 // indirect diff --git a/go.sum b/go.sum index 770a0d3..9bae6cc 100644 --- a/go.sum +++ b/go.sum @@ -1,78 +1,11 @@ -aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk= -aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ= -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/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/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= +github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= -github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= -github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= -github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= -github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -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-20211209193657-4570a0811e8b h1:QAqMVf3pSa6eeTsuklijukjXBlj7Es2QQplab+/RbQ4= -golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -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-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/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/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-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-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-20220310020820-b874c991c1a5 h1:y/woIyUBFbpQGKS0u1aHF/40WUDnek3fPOyD08H5Vng= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.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-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -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.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.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/goca.go b/goca.go index 5ff0bac..f6e5ae3 100644 --- a/goca.go +++ b/goca.go @@ -140,6 +140,12 @@ func printHelp() { fmt.Println(" base Change base currency (e.g., base USD)") fmt.Println(" clear Clear the terminal screen") fmt.Println(" exit/quit Exit goca") + fmt.Println("\033[1mUpdates:\033[0m") + fmt.Println(" goca --version Print the version") + fmt.Println(" goca --check-update Look for a newer release") + fmt.Println(" goca --update Download and install the newest release") + fmt.Println(" goca looks for a new release once a day, in the background, and says so on") + fmt.Println(" stderr. GOCA_NO_UPDATE_CHECK=1 or -y turns that off.") } type CalcCompleter struct{} @@ -533,22 +539,55 @@ func handleLine(line string, useReadline bool, variablesFile string, functionsFi return false } -func main() { - initUnits() +// printUpdateHint puts the note of the daily look on stderr, dimmed, so that it +// never lands in a pipe that only expects the result. +func printUpdateHint(hint string) { + if hint != "" { + fmt.Fprintf(os.Stderr, "\033[2m%s\033[0m\n", hint) + } +} +func main() { + // The options come before everything else: --update-refresh is the + // background run and must touch neither units nor rates, and --version has + // to answer at once — the updater probes the downloaded binary with it. optY := false newArgs := []string{os.Args[0]} for i := 1; i < len(os.Args); i++ { - if os.Args[i] == "-y" { + switch os.Args[i] { + case "-y": optY = true - } else { + case "--version": + fmt.Printf("goca %s\n", Version) + return + case "--update": + if err := selfUpdate.install(os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "goca: %v\n", err) + os.Exit(1) + } + return + case "--check-update": + if err := selfUpdate.check(os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "goca: %v\n", err) + os.Exit(1) + } + return + case updateRefreshFlag: // the background run, not in the help + selfUpdate.refresh() + return + default: newArgs = append(newArgs, os.Args[i]) } } os.Args = newArgs + initUnits() + + // Costs nothing: the hint comes from the note in the cache, and the asking + // happens once a day at most, in the background. -y keeps it quiet. + updateHint := "" if !optY { - checkforupdate(UPDATEURL) + updateHint = selfUpdate.daily() } hasArgs := len(os.Args) > 1 @@ -576,6 +615,7 @@ func main() { if arg == "-p" || arg == "--port" { if i+1 < len(os.Args) { port := os.Args[i+1] + printUpdateHint(updateHint) startWebServer(port) return } else { @@ -587,6 +627,7 @@ func main() { input := strings.Join(os.Args[1:], " ") handleLine(input, false, variablesFile, functionsFile) + printUpdateHint(updateHint) return } @@ -614,6 +655,7 @@ func main() { if useReadline { fmt.Printf("\033[1;34mgoca v%s\033[0m, type 'help' for examples.\n", Version) + printUpdateHint(updateHint) } var scanner *bufio.Scanner @@ -639,4 +681,8 @@ func main() { break } } + + if !useReadline { // piped input: the hint comes at the end, not before it + printUpdateHint(updateHint) + } } diff --git a/notarize.sh b/notarize.sh index 540939f..c49745f 100755 --- a/notarize.sh +++ b/notarize.sh @@ -1,5 +1,9 @@ #!/bin/bash -# Script to sign and notarize goca Darwin binaries +# Script to sign and notarize goca Darwin binaries. +# +# Runs after ./build.sh and before the release is uploaded: it signs the files +# in ./bin in place, so what goes into the Gitea release — and thus what +# --update hands out — is the signed and notarized binary. set -e @@ -21,30 +25,30 @@ fi SIGNING_IDENTITY="Developer ID Application: Mike Wesemann ($TEAM_ID)" # Verify binaries exist -if [ ! -f "dist/goca-darwin-amd64" ] || [ ! -f "dist/goca-darwin-arm64" ]; then - echo "❌ Error: Darwin binaries not found in 'dist/'. Run ./build_releases.sh first." +if [ ! -f "bin/goca-darwin-amd64" ] || [ ! -f "bin/goca-darwin-arm64" ]; then + echo "❌ Error: Darwin binaries not found in 'bin/'. Run ./build.sh first." exit 1 fi echo "🔐 Step 1: Codesigning Darwin binaries..." -codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" dist/goca-darwin-amd64 -codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" dist/goca-darwin-arm64 +codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" bin/goca-darwin-amd64 +codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" bin/goca-darwin-arm64 echo "📦 Step 2: Packaging binaries into ZIP archives..." -mkdir -p dist/notarize -rm -f dist/notarize/*.zip -ditto -c -k --keepParent dist/goca-darwin-amd64 dist/notarize/goca-darwin-amd64.zip -ditto -c -k --keepParent dist/goca-darwin-arm64 dist/notarize/goca-darwin-arm64.zip +mkdir -p bin/notarize +rm -f bin/notarize/*.zip +ditto -c -k --keepParent bin/goca-darwin-amd64 bin/notarize/goca-darwin-amd64.zip +ditto -c -k --keepParent bin/goca-darwin-arm64 bin/notarize/goca-darwin-arm64.zip echo "🚀 Step 3: Submitting macOS Intel binary (amd64) to Apple Notarization..." -xcrun notarytool submit dist/notarize/goca-darwin-amd64.zip \ +xcrun notarytool submit bin/notarize/goca-darwin-amd64.zip \ --apple-id "$APPLE_ID" \ --password "$APPLE_PASSWORD" \ --team-id "$TEAM_ID" \ --wait echo "🚀 Step 4: Submitting macOS Apple Silicon binary (arm64) to Apple Notarization..." -xcrun notarytool submit dist/notarize/goca-darwin-arm64.zip \ +xcrun notarytool submit bin/notarize/goca-darwin-arm64.zip \ --apple-id "$APPLE_ID" \ --password "$APPLE_PASSWORD" \ --team-id "$TEAM_ID" \ diff --git a/selfupdate.go b/selfupdate.go new file mode 100644 index 0000000..3e4a7ab --- /dev/null +++ b/selfupdate.go @@ -0,0 +1,493 @@ +// 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_releases.sh produces: one release per version, +// whose tag is the bare number (1.1.2, a leading "v" is allowed), holding one +// asset "--" each — that is, exactly the files from ./bin. +// Under /api/v1/repos///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. +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "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/goca", + asset: "goca", + current: Version, // from version.go, bumped by build.sh + verify: []string{"--version"}, + every: 24 * time.Hour, + quietEnv: "GOCA_NO_UPDATE_CHECK", +} + +type selfUpdater struct { + repo string // repo URL as in the browser: https://host/owner/repo + asset string // base name of the assets, "--" 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 := fmt.Sprintf("%s-%s-%s", u.asset, runtime.GOOS, runtime.GOARCH) + if runtime.GOOS == "windows" { + want += ".exe" // build_releases.sh keeps the extension on the Windows asset + } + var src *updateAsset + for i := range rel.Assets { + if rel.Assets[i].Name == want { + src = &rel.Assets[i] + break + } + } + 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, ", ")) + } + + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("cannot locate the running binary: %w", err) + } + // An installed goca 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) + 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 +} + +func (u selfUpdater) download(a *updateAsset, exe string, mode os.FileMode) (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() + + n, err := io.Copy(f, 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 { + 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"` +} + +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. +// +// The certificate is verified, plainly, by the default transport. The copy of +// this file in dns talks to the same gitea and does not — that is a concession +// to machines there without a current trust store, and deliberately not made +// here: on a host that cannot build the chain, goca's update check fails loudly +// rather than accepting whatever binary the network hands 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 1.1.10 lands behind 1.1.9 and not in front of it. A leading "v" does not +// count, missing places count as 0 (1.1 == 1.1.0), and a suffix on the number +// makes the version older, not newer (1.1.6-rc1 < 1.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 == "": // 1.1.6 is finished, 1.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 — 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) + } +} diff --git a/update.go b/update.go deleted file mode 100644 index 1ae9911..0000000 --- a/update.go +++ /dev/null @@ -1,127 +0,0 @@ -package main - -import ( - "bufio" - "fmt" - "net/http" - "os" - "path/filepath" - "runtime" - "strings" - - "github.com/AlecAivazis/survey/v2" - "github.com/AlecAivazis/survey/v2/terminal" - "github.com/Masterminds/semver/v3" - "github.com/fatih/color" - "github.com/minio/selfupdate" -) - -var UPDATEURL = "http://gozilla.fhi.mpg.de/goca" - -// Color function variables matching tools.go in dns -var Crb func(...interface{}) string = color.New(color.Bold, color.FgRed).SprintFunc() -var Cgb func(...interface{}) string = color.New(color.Bold, color.FgGreen).SprintFunc() -var Cwb func(...interface{}) string = color.New(color.Bold, color.FgWhite).SprintFunc() - -func SF(format string, a ...any) string { - return fmt.Sprintf(format, a...) -} - -func PE(msg ...string) (n int, err error) { - if len(msg) == 2 { - return fmt.Fprintf(os.Stdout, "%s: %s (%s)\n", Crb("ERROR"), Cwb(msg[0]), msg[1]) - } - return fmt.Fprintf(os.Stdout, "%s: %s\n", Crb("ERROR"), Cwb(msg[0])) -} - -func PO(msg ...string) (n int, err error) { - if len(msg) == 2 { - return fmt.Fprintf(os.Stdout, "%s: %s (%s)\n", Cgb("OK"), Cwb(msg[0]), msg[1]) - } - return fmt.Fprintf(os.Stdout, "%s: %s\n", Cgb("OK"), Cwb(msg[0])) -} - -func checkforupdate(URL string) { // ----------------------------------------------------- check for new version - prg := prgname() - resp, err := http.Get(URL + "/version.txt") - if err == nil { - scanner := bufio.NewScanner(resp.Body) - if scanner.Scan() { - lversion := strings.TrimSpace(scanner.Text()) - sv_version, err := semver.NewVersion(Version) - if err == nil { - sv_lversion, err := semver.NewVersion(lversion) - if err == nil { - if sv_lversion.GreaterThan(sv_version) { - ans := Yesno(SF("new '%s' version found (%s -> %s), update now?", - prg, sv_version, sv_lversion), true, false) - if ans { - updateurl := SF("%s/%s_%s_%s_%s", URL, prg, lversion, runtime.GOOS, runtime.GOARCH) - - if err := doupdate(updateurl); err != nil { - PE(SF("Update failed: %v\n", err)) - os.Exit(1) - } - PO("Update successful!", "please run your last command again") - os.Exit(0) - } - } - } - } - } - resp.Body.Close() - } -} - -func doupdate(url string) error { // ----------------------------------------------------------------- do update - resp, err := http.Get(url) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("server returned status: %v", resp.Status) - } - err = selfupdate.Apply(resp.Body, selfupdate.Options{}) - if err != nil { - return err - } - return nil -} - -func Yesno(msg string, def bool, overwrite bool) bool { // -------------------------- AlecAivazis/survey: yes/no - if overwrite { - return true - } - - var err error - tmp := "" - if def { - err = survey.AskOne(&survey.Select{Message: msg, Options: []string{"Yes", "No"}}, &tmp) - } else { - err = survey.AskOne(&survey.Select{Message: msg, Options: []string{"No", "Yes"}}, &tmp) - } - - if err != nil { - if err == terminal.InterruptErr { - fmt.Fprintln(os.Stdout, Crb("Interrupted.")) - os.Exit(0) - } - } - - if tmp == "Yes" { - return true - } else { - return false - } -} - -func prgname() string { // ---------------------------------------------------------------- program name - exepath, err := os.Executable() - if err != nil { - PE(SF("Error getting executable path: %s", err)) - return "" - } - exename := filepath.Base(exepath) - return exename -} diff --git a/version.go b/version.go index d2f7d4a..30bcf6e 100644 --- a/version.go +++ b/version.go @@ -1,6 +1,6 @@ package main -// Version is the current version of the application. -// It is automatically incremented by the build script. -var Version = "1.1.1" - +// 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 +// `go build`; the version actually built is the one in version.txt. +var Version = "1.1.2" diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..781dcb0 --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +1.1.3