From f600fcaf6db8fab866330c2d1ebb6e4180db733d Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 26 May 2026 10:42:52 -0300 Subject: [PATCH] scripts: add tag-release.sh to safely cut release tags Adds a script that creates a signed annotated release tag only after verifying: 1. The requested tag name matches the version constants committed in HEAD:build/version.go. Catches the failure mode where a release branch is tagged before the version bump has been committed, which would otherwise leave the tagged commit reporting an old version string at runtime. 2. The local HEAD is identical to the upstream lightningnetwork/lnd view of the release branch. A release tag must never point at a commit that has not been merged upstream yet. The upstream remote is discovered by URL rather than by name, since "origin" is conventionally the fork in a "gh repo fork" workflow. The branch defaults to whichever one is currently checked out (typically a release branch such as v0.21.x-branch) and can be overridden with --branch. The script deliberately does not push the tag or auto-bump version.go; both remain explicit human steps. (cherry picked from commit 5320aa0e368c363ec386ec117fda88a03a9a311e) --- scripts/tag-release.sh | 150 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100755 scripts/tag-release.sh diff --git a/scripts/tag-release.sh b/scripts/tag-release.sh new file mode 100755 index 000000000..d07e02cc6 --- /dev/null +++ b/scripts/tag-release.sh @@ -0,0 +1,150 @@ +#!/bin/bash +# +# tag-release.sh creates a signed annotated git tag for an lnd release after +# verifying (a) HEAD is in sync with the upstream lightningnetwork/lnd +# branch, and (b) build/version.go at HEAD matches the requested tag. Guards +# against tagging a commit that has not been merged upstream yet, or one +# whose embedded version disagrees with the tag. + +set -euo pipefail + +VERSION_FILE="build/version.go" + +# Match the canonical upstream URL across https / git@ / ssh:// forms, with or +# without a `.git` suffix. We identify the remote by URL because `origin` is +# conventionally the fork in a `gh repo fork` setup. +UPSTREAM_URL_REGEX='[:/]lightningnetwork/lnd(\.git)?$' + +usage() { + cat >&2 < [--branch ] + + Release tag, e.g. v0.21.0-beta.rc3. Must match the + constants defined in ${VERSION_FILE} at HEAD. + --branch Upstream branch to verify HEAD against. Defaults to + the currently checked-out branch (typically a release + branch such as v0.21.x-branch). +EOF + exit 1 +} + +TAG="" +UPSTREAM_BRANCH="" +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) usage ;; + --branch) [[ $# -ge 2 ]] || usage; UPSTREAM_BRANCH="$2"; shift 2 ;; + --branch=*) UPSTREAM_BRANCH="${1#--branch=}"; shift ;; + -*) echo "Unknown flag: $1" >&2; usage ;; + *) [[ -z "${TAG}" ]] || usage; TAG="$1"; shift ;; + esac +done +[[ -n "${TAG}" ]] || usage + +cd "$(git rev-parse --show-toplevel)" + +if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + echo "Error: tag ${TAG} already exists locally." >&2 + exit 1 +fi + +if [[ -z "${UPSTREAM_BRANCH}" ]]; then + UPSTREAM_BRANCH="$(git symbolic-ref --quiet --short HEAD || true)" + [[ -n "${UPSTREAM_BRANCH}" ]] \ + || { echo "Error: detached HEAD; pass --branch ." >&2; exit 1; } +fi + +# Discover the upstream remote by URL (see UPSTREAM_URL_REGEX). +UPSTREAM_REMOTES=() +while IFS= read -r line; do + UPSTREAM_REMOTES+=("$line") +done < <(git remote -v | awk -v re="${UPSTREAM_URL_REGEX}" \ + '$3 == "(fetch)" && $2 ~ re { print $1 }' | sort -u) + +case "${#UPSTREAM_REMOTES[@]}" in + 0) echo "Error: no git remote points at lightningnetwork/lnd. Add one with" \ + "'git remote add upstream" \ + "https://github.com/lightningnetwork/lnd.git'." >&2 + exit 1 ;; + 1) UPSTREAM_REMOTE="${UPSTREAM_REMOTES[0]}" ;; + *) echo "Error: multiple remotes match lightningnetwork/lnd:" >&2 + printf ' %s\n' "${UPSTREAM_REMOTES[@]}" >&2 + exit 1 ;; +esac + +# Fetch first so every later check runs against confirmed-current upstream +# state. Without this, a stale local HEAD could pass the version-match check +# while still being out of sync with what's on the release branch. +echo "Fetching ${UPSTREAM_REMOTE} ${UPSTREAM_BRANCH}..." +git fetch --quiet "${UPSTREAM_REMOTE}" "${UPSTREAM_BRANCH}" + +# Catch the race where another maintainer has already published this tag. +if git ls-remote --exit-code --tags "${UPSTREAM_REMOTE}" \ + "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "Error: tag ${TAG} already exists on ${UPSTREAM_REMOTE}." >&2 + exit 1 +fi + +# Compare against FETCH_HEAD rather than refs/remotes//: +# FETCH_HEAD is always written by `git fetch `, while the +# remote-tracking ref depends on the user's refspec configuration. +HEAD_SHA="$(git rev-parse HEAD)" +UP_SHA="$(git rev-parse FETCH_HEAD)" +if [[ "${HEAD_SHA}" != "${UP_SHA}" ]]; then + AHEAD="$(git rev-list --count FETCH_HEAD..HEAD)" + BEHIND="$(git rev-list --count HEAD..FETCH_HEAD)" + cat >&2 </dev/null | awk ' + /^[[:space:]]*AppMajor[[:space:]]+uint[[:space:]]*=/ { sub(/.*=[[:space:]]*/,""); sub(/[^0-9].*/,""); print } + /^[[:space:]]*AppMinor[[:space:]]+uint[[:space:]]*=/ { sub(/.*=[[:space:]]*/,""); sub(/[^0-9].*/,""); print } + /^[[:space:]]*AppPatch[[:space:]]+uint[[:space:]]*=/ { sub(/.*=[[:space:]]*/,""); sub(/[^0-9].*/,""); print } + /^[[:space:]]*AppPreRelease[[:space:]]*=/ { match($0,/"[^"]*"/); print substr($0,RSTART+1,RLENGTH-2) } + ' +) + +if [[ -z "${M}" || -z "${m}" || -z "${p}" ]]; then + echo "Error: failed to parse version constants from HEAD:${VERSION_FILE}." \ + >&2 + exit 1 +fi + +# Go treats `01` as an octal literal but %d prints it as decimal; force +# base-10 here so we match build.Version()'s output. +EXPECTED="v$((10#$M)).$((10#$m)).$((10#$p))" +[[ -n "${pre}" ]] && EXPECTED="${EXPECTED}-${pre}" + +echo "Requested: ${TAG}" +echo "Expected: ${EXPECTED} (from HEAD:${VERSION_FILE})" + +if [[ "${TAG}" != "${EXPECTED}" ]]; then + cat >&2 <