Files
arr-tui/scripts/release.sh
T
2026-10-20 22:26:17 +02:00

132 lines
5.7 KiB
Bash
Executable File

#!/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"