blob: 54e4436bfc8716a2101f9c3043739bccc14cc494 [file]
#!/usr/bin/env bash
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Apache SkyWalking NodeJS — release-candidate automation.
#
# Adapted from apache/skywalking-horizon-ui scripts/release.sh for the
# single-package npm layout of skywalking-nodejs. Produces the Apache source
# release, stages it for the vote, and prints the [VOTE] email:
#
# skywalking-nodejs-src-<v>.tgz {.asc,.sha512}
#
# The post-vote half lives in scripts/release-finalize.sh.
#
# VERSIONING: master carries the in-flight dev version (e.g. 0.9.0-dev), like
# SkyWalking's -SNAPSHOT / horizon-ui's -dev. In a FRESH recursive clone this
# script cuts a release branch, strips the -dev suffix and commits + tags that
# commit (the release candidate), then adds a second commit bumping back to the
# next dev version, and opens ONE PR carrying both. The tag is pushed ONLY AFTER
# the artifacts are built, signed and self-verified, so a build failure never
# leaves a public, immutable release tag behind. Merge the PR after the vote
# passes (master then returns to -dev with the release in its history).
#
# Run interactively on a single-user trusted host (it reads your SVN
# password). Requires bash, but is written to work on macOS bash 3.2.
#
# Usage: bash scripts/release.sh
set -e -o pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PROJECT_DIR=$(cd "${SCRIPT_DIR}/.." && pwd)
REPO_URL="${SW_RELEASE_REPO_URL:-https://github.com/apache/skywalking-nodejs.git}"
REPO_BRANCH="${SW_RELEASE_BRANCH:-master}"
GH_REPO="${SW_RELEASE_GH_REPO:-apache/skywalking-nodejs}"
SVN_DEV_URL="https://dist.apache.org/repos/dist/dev/skywalking/node-js"
KEYS_URL="https://dist.apache.org/repos/dist/release/skywalking/KEYS"
WORK_DIR="${SCRIPT_DIR}/.release-work"
CLONE_DIR="${WORK_DIR}/skywalking-nodejs"
# --dry-run / SW_RELEASE_DRY_RUN=1: run the full LOCAL pipeline (clone, strip -dev,
# build, sign, verify) but perform ZERO remote mutations — no tag/branch push, no
# svn upload, no PR. Lets a committer rehearse against the real repo with nothing to undo.
DRY_RUN=false
if [ "${1:-}" = "--dry-run" ] || [ -n "${SW_RELEASE_DRY_RUN:-}" ]; then
DRY_RUN=true
fi
TEST_FILE=""
# Always clean up the throwaway GPG test file, even on Ctrl-C / early exit.
trap 'rm -f "${TEST_FILE:-}" "${TEST_FILE:-}.asc"' EXIT
# ========================== Helpers ==========================
err() { echo "ERROR: $*" >&2; }
note() { echo ""; echo "=== $* ==="; }
confirm() {
local prompt="$1" ans
read -r -p "${prompt} [y/N] " ans || { err "No input (no TTY?)."; exit 1; }
[[ "$ans" == "y" || "$ans" == "Y" ]]
}
# Prompt for a required, non-empty value. $1=prompt var-name is echoed back via stdout.
ask() {
local prompt="$1" val
read -r -p "${prompt}: " val || { err "No input for '${prompt}' (no TTY?)."; exit 1; }
[ -n "$val" ] || { err "'${prompt}' must not be empty."; exit 1; }
printf '%s' "$val"
}
# Read the package.json "version" without jq (runs on stock macOS / Alpine).
read_version() {
node -e "process.stdout.write(JSON.parse(require('fs').readFileSync('$1','utf8')).version)"
}
$DRY_RUN && note "DRY RUN — full local build + sign + verify, but NO tag/branch push, NO svn upload, NO PR"
# ========================== Step 1: GPG signer ==========================
note "Step 1 — GPG signer check"
GPG_KEY_ID=$(git config user.signingkey 2>/dev/null || true)
if [ -z "$GPG_KEY_ID" ]; then
GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG 2>/dev/null | grep -A1 '^sec' | tail -1 | awk '{print $1}' || true)
fi
if [ -z "$GPG_KEY_ID" ]; then
err "No GPG secret key found. Configure your Apache GPG key first."
exit 1
fi
# Match @apache.org against ANY uid of the SELECTED key (not just the first
# uid of a blind dump), and tolerate an empty result without aborting.
GPG_EMAILS=$(gpg --list-keys --with-colons "${GPG_KEY_ID}" 2>/dev/null | awk -F: '/^uid:/{print $10}' || true)
if ! printf '%s\n' "${GPG_EMAILS}" | grep -q '@apache\.org'; then
err "Key ${GPG_KEY_ID} has no @apache.org uid — Apache releases must be signed with an @apache.org key."
err "uids found: ${GPG_EMAILS:-<none>}"
exit 1
fi
echo "GPG Key: ${GPG_KEY_ID}"
gpg --list-keys --keyid-format LONG "${GPG_KEY_ID}" | grep -E '^(pub|uid)' || true
echo "Reminder: this key MUST already be in ${KEYS_URL} — every voter verifies against it."
confirm "Is this the correct signer?" || { echo "Aborted."; exit 1; }
# Pin the signer end-to-end: package.json's release-src honors SW_GPG_KEY
# (gpg -u), so the tarball is signed by THIS key, not gpg's default key.
export SW_GPG_KEY="${GPG_KEY_ID}"
export GPG_TTY=$(tty || true)
echo "Verifying GPG signing works (you may be prompted for the passphrase)…"
TEST_FILE=$(mktemp); echo "test" > "${TEST_FILE}"
if ! gpg --batch --yes -u "${GPG_KEY_ID}" --armor --detach-sig "${TEST_FILE}" 2>/dev/null; then
err "GPG signing with ${GPG_KEY_ID} failed. Try: export GPG_TTY=\$(tty) / gpgconf --launch gpg-agent"
exit 1
fi
rm -f "${TEST_FILE}" "${TEST_FILE}.asc"; TEST_FILE=""
echo "GPG signing OK (signer pinned to ${GPG_KEY_ID})."
# ========================== Step 2: Required tools ==========================
note "Step 2 — Tool check"
MISSING=()
for t in gpg svn shasum git gh node npm tar; do
command -v "$t" >/dev/null || MISSING+=("$t")
done
if [ ${#MISSING[@]} -gt 0 ]; then
err "Missing required tools: ${MISSING[*]}"
exit 1
fi
HAVE_LICENSE_EYE=true
command -v license-eye >/dev/null || HAVE_LICENSE_EYE=false
echo "All required tools present. node: $(node --version) npm: $(npm --version)"
$HAVE_LICENSE_EYE || echo "NOTE: license-eye not installed — header check will be skipped (CI still enforces it)."
# Node baseline: engines.node >=20, and grpc-tools' node-pre-gyp needs >=18.17.
NODE_MAJOR=$(node -e "process.stdout.write(String(process.versions.node.split('.')[0]))")
[[ "${NODE_MAJOR}" =~ ^[0-9]+$ ]] || { err "Could not parse Node major version ('${NODE_MAJOR}')."; exit 1; }
if [ "${NODE_MAJOR}" -lt 20 ]; then
err "Node ${NODE_MAJOR}.x is below the >=20 baseline; grpc-tools install may also fail. Use Node 20/22/24."
exit 1
fi
# ========================== Step 3: Detect version ==========================
note "Step 3 — Detect version"
# master carries the in-flight dev version (e.g. 0.9.0-dev). The release version
# is the bare semver; the next dev version bumps the minor (patch reset to 0).
CURRENT_VERSION=$(read_version "${PROJECT_DIR}/package.json")
[ -n "$CURRENT_VERSION" ] || { err "Could not read version from package.json."; exit 1; }
RELEASE_VERSION="${CURRENT_VERSION%-dev}"
RELEASE_VERSION="${RELEASE_VERSION%-SNAPSHOT}"
if [ "${CURRENT_VERSION}" = "${RELEASE_VERSION}" ]; then
err "package.json version '${CURRENT_VERSION}' has no -dev/-SNAPSHOT suffix."
err "master must carry the dev-suffixed version between releases; bump it first."
exit 1
fi
if ! printf '%s' "${RELEASE_VERSION}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
err "Release version '${RELEASE_VERSION}' (from '${CURRENT_VERSION}') is not X.Y.Z."; exit 1
fi
MAJOR=$(printf '%s' "${RELEASE_VERSION}" | cut -d. -f1)
MINOR=$(printf '%s' "${RELEASE_VERSION}" | cut -d. -f2)
NEXT_DEV_VERSION="${MAJOR}.$((MINOR + 1)).0-dev"
echo "Current (master): ${CURRENT_VERSION}"
echo "Release: ${RELEASE_VERSION}"
echo "Next dev: ${NEXT_DEV_VERSION}"
if ! confirm "Are these correct?"; then
RELEASE_VERSION=$(ask "Enter the release version (X.Y.Z)")
NEXT_DEV_VERSION=$(ask "Enter the next dev version (e.g. 0.10.0-dev)")
printf '%s' "${RELEASE_VERSION}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' \
|| { err "Release version must be X.Y.Z (got '${RELEASE_VERSION}')."; exit 1; }
printf '%s' "${NEXT_DEV_VERSION}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+-dev$' \
|| { err "Next dev version must be X.Y.Z-dev (got '${NEXT_DEV_VERSION}')."; exit 1; }
[ "${NEXT_DEV_VERSION}" != "${RELEASE_VERSION}" ] \
|| { err "Next dev version must differ from the release version."; exit 1; }
fi
TAG="v${RELEASE_VERSION}"
RELEASE_BRANCH="prepare-release-${RELEASE_VERSION}"
# ========================== Step 4: Consistency check ==========================
note "Step 4 — Consistency check"
for f in LICENSE NOTICE package.json; do
[ -f "${PROJECT_DIR}/${f}" ] || { err "${f} missing at repo root."; exit 1; }
done
echo "LICENSE / NOTICE / package.json present."
# Soft check: README's advertised baseline should not contradict engines.node.
if grep -qiE 'NodeJS *>= *([0-9]|1[0-9])\b' "${PROJECT_DIR}/README.md" 2>/dev/null &&
! grep -qiE 'NodeJS *>= *2[0-9]' "${PROJECT_DIR}/README.md" 2>/dev/null; then
echo "WARN: README.md advertises a Node baseline below 20 while engines.node is >=20. Fix before tagging."
fi
# ========================== Step 5: License-header check ==========================
if $HAVE_LICENSE_EYE; then
note "Step 5 — License-header check (license-eye)"
(cd "${PROJECT_DIR}" && license-eye -c .licenserc.yaml header check)
echo "License headers OK."
else
note "Step 5 — License-header check SKIPPED (license-eye absent; CI enforces it)"
fi
# ========================== Step 6: Clone fresh, strip -dev, commit + tag LOCALLY ==========================
note "Step 6 — Fresh clone, strip -dev, commit + local tag ${TAG}"
rm -rf "${WORK_DIR}"; mkdir -p "${WORK_DIR}"
# --recurse-submodules is MANDATORY: protocol/ holds the .proto sources;
# without it prepare->protoc.sh codegens nothing (Step 8 guards against this).
echo "Cloning ${REPO_URL} (branch ${REPO_BRANCH}) with submodules…"
git clone --recurse-submodules --branch "${REPO_BRANCH}" "${REPO_URL}" "${CLONE_DIR}"
CLONE_VERSION=$(read_version "${CLONE_DIR}/package.json")
if [ "${CLONE_VERSION}" != "${CURRENT_VERSION}" ]; then
err "Fresh clone of ${REPO_BRANCH} has version ${CLONE_VERSION}, expected ${CURRENT_VERSION}."
err "Re-run after ${REPO_BRANCH} settles, or fix its package.json version."
exit 1
fi
cd "${CLONE_DIR}"
# Capture ls-remote success explicitly: a FAILED ls-remote must not be read
# as "tag/branch absent" (which would let us re-create an existing one).
REMOTE_TAGS=$(git ls-remote --tags origin) || { err "git ls-remote origin failed; cannot verify ${TAG} is unused."; exit 1; }
if printf '%s\n' "${REMOTE_TAGS}" | grep -q "refs/tags/${TAG}$"; then
err "Tag ${TAG} already exists on origin. Delete it first to re-cut, or pick a new version."
exit 1
fi
REMOTE_HEADS=$(git ls-remote --heads origin "${RELEASE_BRANCH}") || { err "git ls-remote origin failed."; exit 1; }
if [ -n "${REMOTE_HEADS}" ]; then
err "Branch ${RELEASE_BRANCH} already exists on origin. Close/delete it first to re-cut."
exit 1
fi
# The release commit lands via PR (master is protected). Cut a dedicated branch;
# the tag points at the version-strip commit, and the next-dev bump is added as a
# second commit on the SAME branch in Step 9 — so one PR carries both.
git checkout -q -b "${RELEASE_BRANCH}"
npm version "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version >/dev/null
# Install deps BEFORE committing so the committed (and tagged) lockfile is exactly
# the one the tarball ships — no post-build drift. This also runs prepare->protoc.sh
# (needs the protocol/ submodule + grpc-tools; on Apple Silicon protoc runs via Rosetta).
npm install
git add package.json package-lock.json
git commit -q --no-verify -m "Prepare release ${RELEASE_VERSION}"
# Tag the version-strip commit LOCALLY only. It is pushed in Step 9, AFTER the
# artifacts are built + verified — never before. Use ^{commit} so RELEASE_COMMIT is
# the COMMIT sha (an annotated tag's own object sha would 404 in the vote email).
git tag -a "${TAG}" -m "Release Apache SkyWalking-NodeJS ${RELEASE_VERSION}"
RELEASE_COMMIT=$(git rev-parse "${TAG}^{commit}")
echo "Branch ${RELEASE_BRANCH} + local tag ${TAG} at ${RELEASE_COMMIT} (NOT pushed yet)."
# ========================== Step 7: Build + sign source release ==========================
note "Step 7 — Build + sign source tarball (npm run release-src)"
# Deps were installed in Step 6 (before the commit). release-src runs prepare
# (protoc) + package-src (tar) + gpg detached sig (signer pinned via SW_GPG_KEY)
# + sha512, from the tagged working tree.
npm run release-src
SRC_TGZ="skywalking-nodejs-src-${RELEASE_VERSION}.tgz"
for f in "${SRC_TGZ}" "${SRC_TGZ}.asc" "${SRC_TGZ}.sha512"; do
[ -f "${CLONE_DIR}/${f}" ] || { err "Expected artifact ${f} not produced by release-src."; exit 1; }
cp "${CLONE_DIR}/${f}" "${WORK_DIR}/"
done
# ========================== Step 8: Verify the tarball ==========================
note "Step 8 — Verify artifact contents + signature"
cd "${WORK_DIR}"
PROTO_COUNT=$(tar -tzf "${SRC_TGZ}" | grep -cE 'protocol/.*\.proto' || true)
[ "${PROTO_COUNT}" -gt 0 ] || { err "Tarball contains 0 protocol/*.proto — submodule was empty. Aborting."; exit 1; }
if ! tar -tzf "${SRC_TGZ}" | grep -qE '(^|/)LICENSE$'; then err "Tarball missing LICENSE."; exit 1; fi
if ! tar -tzf "${SRC_TGZ}" | grep -qE '(^|/)NOTICE$'; then err "Tarball missing NOTICE."; exit 1; fi
if tar -tzf "${SRC_TGZ}" | grep -q 'node_modules/'; then err "Tarball unexpectedly contains node_modules/."; exit 1; fi
echo "Contents OK: ${PROTO_COUNT} .proto files, LICENSE + NOTICE present, no node_modules."
shasum -a 512 -c "${SRC_TGZ}.sha512"
gpg --verify "${SRC_TGZ}.asc" "${SRC_TGZ}"
echo "Checksum + signature self-verify OK."
echo "Artifacts:"; ls -lh "${WORK_DIR}/${SRC_TGZ}" "${WORK_DIR}/${SRC_TGZ}.asc" "${WORK_DIR}/${SRC_TGZ}.sha512"
# ========================== Step 9: Push tag + branch, next-dev bump, open PR ==========================
note "Step 9 — Push tag ${TAG} + branch ${RELEASE_BRANCH}, open the release PR"
cd "${CLONE_DIR}"
# Add the next-dev bump as a SECOND commit on the branch so one PR carries both:
# version strip (tagged commit 1) -> back to -dev (commit 2). The tag stays on
# commit 1. (checkout guards against any stray working-tree drift first.)
git checkout -q -- package.json package-lock.json 2>/dev/null || true
npm version "${NEXT_DEV_VERSION}" --no-git-tag-version >/dev/null
git add package.json package-lock.json
git commit -q --no-verify -m "Prepare next release ${NEXT_DEV_VERSION}"
TAG_PUSHED=false
if $DRY_RUN; then
echo "DRY RUN: prepared branch ${RELEASE_BRANCH} (2 commits) + local tag ${TAG} in ${CLONE_DIR}."
echo "DRY RUN: skipping push of branch + tag and the release PR."
elif confirm "Artifacts built + verified. Push tag ${TAG} + branch ${RELEASE_BRANCH} (atomic) and open the release PR now?"; then
# --atomic: both refs land together or neither, avoiding a branch-without-tag
# half-state that the re-run guards would then trip on.
git push --atomic origin "${RELEASE_BRANCH}" "${TAG}"
TAG_PUSHED=true
echo "Pushed ${RELEASE_BRANCH} + ${TAG}."
if gh pr create --repo "${GH_REPO}" --base "${REPO_BRANCH}" --head "${RELEASE_BRANCH}" \
--title "Release ${RELEASE_VERSION}, bump to ${NEXT_DEV_VERSION}" \
--body "Release branch for ${RELEASE_VERSION} — two commits: strip -dev (tagged ${TAG}, the RC the vote runs against) and bump to ${NEXT_DEV_VERSION}. Merge AFTER the [VOTE] passes; the ${TAG} tag stays pinned to the release commit."; then
echo "Opened release PR: ${RELEASE_BRANCH} -> ${REPO_BRANCH}."
else
echo "Could not open the PR automatically — open it by hand (${RELEASE_BRANCH} -> ${REPO_BRANCH})."
fi
else
echo "Tag ${TAG} + branch ${RELEASE_BRANCH} NOT pushed; the prepared clone is in ${CLONE_DIR}."
echo "To finish by hand: git -C ${CLONE_DIR} push --atomic origin ${RELEASE_BRANCH} ${TAG}, then open the PR."
fi
# ========================== Step 10: Upload RC to svn dev ==========================
note "Step 10 — Upload RC to ${SVN_DEV_URL}/${RELEASE_VERSION}"
UPLOADED=false
if $DRY_RUN; then
echo "DRY RUN: skipping svn upload to ${SVN_DEV_URL}/${RELEASE_VERSION}."
elif confirm "Upload the release candidate to svn dev now?"; then
# NOTE: svn takes the password on argv (--password), so it is briefly
# visible in `ps`. Run this only on a single-user trusted host and never
# with `set -x`. The password is cleared from the environment below.
SVN_USER=$(ask "Apache SVN username")
read -r -s -p "Apache SVN password: " SVN_PASS || { err "No SVN password (no TTY?)."; exit 1; }
echo ""
[ -n "$SVN_PASS" ] || { err "SVN password must not be empty."; exit 1; }
SVN_AUTH=(--username "${SVN_USER}" --password "${SVN_PASS}" --non-interactive --no-auth-cache)
SVN_STAGE="${WORK_DIR}/svn-staging"
rm -rf "${SVN_STAGE}"
svn co --depth empty "${SVN_AUTH[@]}" "${SVN_DEV_URL}" "${SVN_STAGE}"
SVN_VERSION_DIR="${SVN_STAGE}/${RELEASE_VERSION}"
if svn ls "${SVN_AUTH[@]}" "${SVN_DEV_URL}/${RELEASE_VERSION}" >/dev/null 2>&1; then
echo "Version folder exists on svn — updating in place."
svn update "${SVN_AUTH[@]}" --set-depth infinity "${SVN_VERSION_DIR}"
else
mkdir -p "${SVN_VERSION_DIR}"
fi
cp "${WORK_DIR}/${SRC_TGZ}" "${WORK_DIR}/${SRC_TGZ}.asc" "${WORK_DIR}/${SRC_TGZ}.sha512" "${SVN_VERSION_DIR}/"
(cd "${SVN_STAGE}" && svn add --force "${RELEASE_VERSION}" || true)
(cd "${SVN_STAGE}" && svn commit "${SVN_AUTH[@]}" -m "Draft Apache SkyWalking-NodeJS release ${RELEASE_VERSION}")
UPLOADED=true
echo "Uploaded: ${SVN_DEV_URL}/${RELEASE_VERSION}"
unset SVN_PASS; unset SVN_AUTH
else
echo "Skipped svn upload. Artifacts are in ${WORK_DIR}/."
fi
# ========================== Step 11: Vote email ==========================
note "Step 11 — Vote email"
if ! $TAG_PUSHED || ! $UPLOADED; then
echo "WARNING: tag pushed=${TAG_PUSHED}, RC uploaded=${UPLOADED}."
echo " Some links in the email below are DEAD until you push the tag and/or upload the RC."
echo ""
fi
SRC_SHA512=$(cat "${WORK_DIR}/${SRC_TGZ}.sha512")
VOTE_DATE=$(LC_ALL=C date +"%B %d, %Y")
cat <<EOF
========================================================================
Vote Email copy and send to dev@skywalking.apache.org
========================================================================
Subject: [VOTE] Release Apache SkyWalking NodeJS version ${RELEASE_VERSION}
Hi the SkyWalking Community,
This is a call for vote to release Apache SkyWalking NodeJS version ${RELEASE_VERSION}.
Release notes:
* https://github.com/apache/skywalking-nodejs/releases/tag/${TAG}
Release Candidate:
* ${SVN_DEV_URL}/${RELEASE_VERSION}
* sha512 checksums
- ${SRC_SHA512}
Release Tag :
* (Git Tag) ${TAG}
Release Commit Hash :
* https://github.com/apache/skywalking-nodejs/tree/${RELEASE_COMMIT}
Keys to verify the Release Candidate :
* ${KEYS_URL}
Guide to build the release from source :
* https://github.com/apache/skywalking-nodejs/blob/${TAG}/CONTRIBUTING.md#compiling-and-building
Voting will start now (${VOTE_DATE}) and will remain open for at least 72 hours.
A release passes with at least 3 binding +1 (PMC) votes and more +1 than -1.
[ ] +1 Release this package.
[ ] +0 No opinion.
[ ] -1 Do not release this package because....
Thanks.
[1] https://github.com/apache/skywalking-nodejs/blob/master/docs/How-to-release.md#vote-check
========================================================================
EOF
note "Done — $($DRY_RUN && echo 'DRY RUN for' || echo 'release candidate') ${RELEASE_VERSION}$($DRY_RUN || echo ' staged')"
$DRY_RUN && echo " (DRY RUN: nothing was pushed/uploaded; the prepared clone is in ${CLONE_DIR}.)"
echo " Tag: ${TAG} ($($TAG_PUSHED && echo pushed || echo 'NOT pushed'))"
echo " Artifacts: ${WORK_DIR}/${SRC_TGZ}{,.asc,.sha512}"
echo " svn dev staging: $($UPLOADED && echo "${SVN_DEV_URL}/${RELEASE_VERSION}" || echo 'NOT uploaded')"
echo ""
echo "Next steps:"
echo " 1. Draft the GitHub release notes (auto-generated; CHANGELOG.md is a stub):"
echo " gh release create ${TAG} --draft --generate-notes --verify-tag \\"
echo " --notes-start-tag <previous tag> --title \"${RELEASE_VERSION}\""
echo " 2. Send the [VOTE] email above to dev@skywalking.apache.org (>=72h)."
echo " 3. After the vote passes:"
echo " - run: bash scripts/release-finalize.sh"
echo " - merge the release PR (${RELEASE_BRANCH} -> ${REPO_BRANCH}); ${REPO_BRANCH} returns to ${NEXT_DEV_VERSION}, tag ${TAG} stays put."