diff --git a/Makefile b/Makefile index e05c7fb..6e829d5 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) LDFLAGS := -s -w -X main.version=$(VERSION) TARGETS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 -.PHONY: build test dist clean +.PHONY: build test dist release clean build: go build -ldflags '$(LDFLAGS)' -o arr-tui ./cmd/arr-tui @@ -22,5 +22,9 @@ dist: done cd dist && sha256sum arr-tui-* > SHA256SUMS +# Publish the release for the tag at HEAD on Gitea (see scripts/release.sh). +release: + scripts/release.sh + clean: rm -rf dist arr-tui diff --git a/README.md b/README.md index ef162fc..6c98fe7 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,15 @@ Keep the file private (`chmod 600`); it holds your API keys. ## Development `dev/` has a compose file with pinned Radarr, Sonarr and SABnzbd containers plus a fake indexer; -see [dev/README.md](dev/README.md). `make test` runs vet and the unit tests. The design and +see [dev/README.md](dev/README.md). +`make test` runs vet and the unit tests. The design and milestone history are in [SPECS.md](SPECS.md). +Releases: tag and push, then `scripts/release.sh` (or `make release`) builds the binaries and publishes +them on Gitea with the matching `CHANGELOG.md` section. It needs an access token with write access +to the repository in `$GITEA_TOKEN` or `~/.config/arr-tui-release-token`; `--check` verifies +everything without publishing, `--dry-run` builds without publishing. + ## License MIT, see [LICENSE](LICENSE). diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..3a18587 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Publish a release on Gitea: create the release for an existing tag from its +# CHANGELOG.md section and attach the binaries built by `make dist`. +# +# scripts/release.sh [--dry-run|--check] [vX.Y.Z] +# +# The tag defaults to the one at HEAD. It must be pushed already. The token +# (Gitea: Settings -> Applications, repository scope with write access) is +# read from $GITEA_TOKEN or from the file $GITEA_TOKEN_FILE, which defaults to +# ~/.config/arr-tui-release-token. It never appears in the process list or in +# any output. The server and repository come from the "origin" remote; set +# GITEA_URL (e.g. https://git.example.com) if its web address differs. +# +# --dry-run build and show what would be published, change nothing +# --check only verify the token, the repository and that the release is free +set -euo pipefail + +cd "$(dirname "$0")/.." + +mode=release +tag= +for arg in "$@"; do + case $arg in + --dry-run) mode=dry ;; + --check) mode=check ;; + -h|--help) sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + v*) tag=$arg ;; + *) echo "unknown argument: $arg" >&2; exit 2 ;; + esac +done + +die() { echo "release: $*" >&2; exit 1; } + +# --- repository and server from the origin remote --------------------------- +origin=$(git remote get-url origin) || die "no origin remote" +if [[ $origin =~ ^ssh://[^@]+@([^:/]+)(:[0-9]+)?/(.+)$ ]]; then + host=${BASH_REMATCH[1]} path=${BASH_REMATCH[3]} +elif [[ $origin =~ ^[^@]+@([^:]+):(.+)$ ]]; then + host=${BASH_REMATCH[1]} path=${BASH_REMATCH[2]} +elif [[ $origin =~ ^https?://([^/]+)/(.+)$ ]]; then + host=${BASH_REMATCH[1]} path=${BASH_REMATCH[2]} +else + die "cannot parse the origin remote: $origin" +fi +repo=${path%.git} +base=${GITEA_URL:-https://$host} +api=$base/api/v1/repos/$repo + +# --- tag -------------------------------------------------------------------- +[[ -n $tag ]] || tag=$(git tag --points-at HEAD | grep '^v' | head -n1) || true +[[ -n $tag ]] || die "no tag given and none at HEAD" +git rev-parse -q --verify "refs/tags/$tag" >/dev/null || die "tag $tag does not exist" +git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1 || die "tag $tag is not pushed to origin" +[[ $(git rev-parse "$tag^{commit}") == "$(git ls-remote origin "refs/tags/$tag^{}" | cut -f1)" ]] \ + || die "tag $tag differs from the one on origin" + +# --- token ------------------------------------------------------------------ +token=${GITEA_TOKEN:-} +if [[ -z $token ]]; then + file=${GITEA_TOKEN_FILE:-$HOME/.config/arr-tui-release-token} + [[ -r $file ]] || die "no token: set GITEA_TOKEN or put it in $file" + [[ $(stat -c %a "$file" 2>/dev/null || stat -f %Lp "$file") =~ ^[0-7]00$ ]] \ + || echo "release: warning: $file is readable by other users, run chmod 600" >&2 + token=$(tr -d '[:space:]' < "$file") +fi +[[ -n $token ]] || die "the token is empty" + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +umask 077 +# curl reads the header from a file so the token is not visible in `ps`. +printf 'Authorization: token %s\n' "$token" > "$tmp/auth" +unset token +call() { curl -sS -H "@$tmp/auth" "$@"; } + +# --- checks against the server ---------------------------------------------- +info=$(call "$api") || die "cannot reach $base" +python3 - "$info" "$repo" <<'PY' || die "no access to $repo (token or repository wrong)" +import json, sys +d = json.loads(sys.argv[1]) +if d.get("full_name", "").lower() != sys.argv[2].lower() or not d.get("permissions", {}).get("push"): + sys.exit(1) +PY +existing=$(call -o /dev/null -w '%{http_code}' "$api/releases/tags/$tag") +[[ $existing == 404 ]] || die "a release for $tag already exists (HTTP $existing)" +echo "release: $repo on $base, $tag is free" +[[ $mode == check ]] && exit 0 + +# --- release text from the changelog ------------------------------------------ +version=${tag#v} +python3 - "$tag" "$tmp/release.json" <<'PY' || die "CHANGELOG.md has no section for $tag" +import json, re, sys +tag, out = sys.argv[1], sys.argv[2] +text = open("CHANGELOG.md").read() +m = re.search(r"^## " + re.escape(tag) + r"\b[^\n]*\n(.*?)(?=^## |\Z)", text, re.S | re.M) +if not m or not m.group(1).strip(): + sys.exit(1) +body = m.group(1).strip() + ( + "\n\nBinaries are attached for linux, macOS (amd64 and arm64) and windows; " + "verify them with `SHA256SUMS`. Build from source with `make dist`.") +json.dump({"tag_name": tag, "name": "arr-tui " + tag, "body": body, + "draft": False, "prerelease": "-" in tag}, open(out, "w")) +PY + +# --- build ---------------------------------------------------------------------- +make test >/dev/null || die "tests failed" +make dist VERSION="$tag" >/dev/null +assets=(dist/*) +echo "release: built ${#assets[@]} files:" +for a in "${assets[@]}"; do echo " $(basename "$a")"; done +[[ $(./dist/arr-tui-linux-amd64 --version 2>/dev/null || true) == "arr-tui $tag" ]] \ + || echo "release: note: ./dist/arr-tui-linux-amd64 --version does not report $tag (not runnable here?)" >&2 + +if [[ $mode == dry ]]; then + echo "release: dry run, nothing published. Release text:" + python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["body"])' "$tmp/release.json" + exit 0 +fi + +# --- publish -------------------------------------------------------------------- +id=$(call -H 'Content-Type: application/json' -d "@$tmp/release.json" "$api/releases" \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("id") or ""); sys.exit(0 if d.get("id") else 1)') \ + || die "creating the release failed" +for a in "${assets[@]}"; do + name=$(basename "$a") + call -F "attachment=@$a" "$api/releases/$id/assets?name=$name" \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("name") else 1)' \ + || die "uploading $name failed (the release $id exists without it)" + echo "release: uploaded $name" +done +echo "release: $base/$repo/releases/tag/$tag"