OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / release.sh

302 lines · bash ref:
#!/usr/bin/env bash
#
# release.sh — keyvault-integrated, SSH-signed release cutter for HiveRelay.
#
# No gpg required: tags are signed with git's SSH signing (gpg.format=ssh),
# using a release signing key held in keyvault (../keyvault). Unlock the vault
# once (`keyvault agent --daemonize`, or run this under `keyvault exec`) and this
# script does the rest — sign, self-verify, push, GitHub prerelease, and
# (optionally) promote the fleet canary channel.
#
# Subcommands:
#   setup                 One-time: generate an ed25519 release key, store the
#                         PRIVATE half in keyvault, add the PUBLIC half to
#                         fleet/allowed-signers (what the fleet's verify_tag uses).
#   cut <vX.Y.Z> [<ref>]  Sign + verify + push the tag, create a GitHub prerelease.
#                         Optional: --promote-canary also bumps channels.json.
#   status                Show what's configured (vault key present? git version?
#                         signer email? allowed-signers populated?).
#
# One-command release (agent unlocked, or prompts once):
#   keyvault exec --scope hiverelay-release -- scripts/release.sh cut v0.24.0
#     (injects TAG_SIGNING_KEY [+ GH_TOKEN if stored] from the vault)
#   — or, with the agent already up —
#   scripts/release.sh cut v0.24.0
#
set -euo pipefail
unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_CONFIG GIT_CONFIG_COUNT
unset GIT_CONFIG_GLOBAL GIT_CONFIG_PARAMETERS GIT_CONFIG_SYSTEM GIT_DIR GIT_EXEC_PATH
unset GIT_INDEX_FILE GIT_NAMESPACE GIT_OBJECT_DIRECTORY GIT_PREFIX GIT_PROXY_COMMAND
unset GIT_QUARANTINE_PATH GIT_REPLACE_REF_BASE GIT_SHALLOW_FILE GIT_SSH GIT_SSH_COMMAND
unset GIT_SSL_CAINFO GIT_SSL_CAPATH GIT_SSL_NO_VERIFY GIT_WORK_TREE
export GIT_NO_REPLACE_OBJECTS=1
export GIT_GRAFT_FILE=/dev/null/hiverelay-disabled
export GIT_CONFIG_COUNT=2
export GIT_CONFIG_KEY_0=core.hooksPath
export GIT_CONFIG_VALUE_0=/dev/null
export GIT_CONFIG_KEY_1=core.fsmonitor
export GIT_CONFIG_VALUE_1=false
GIT_BIN=/usr/bin/git
[ -x "$GIT_BIN" ] && [ -f "$GIT_BIN" ] && [ ! -L "$GIT_BIN" ] || {
  printf '%s\n' 'release: ERROR: trusted Git executable is unavailable at /usr/bin/git' >&2
  exit 1
}
git() { "$GIT_BIN" "$@"; }

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ALLOWED_SIGNERS="$REPO_ROOT/fleet/allowed-signers"

# --- configurable (env overridable) ---------------------------------------
VAULT_SCOPE="${HIVERELAY_RELEASE_VAULT_SCOPE:-hiverelay-release}"
VAULT_KEY_NAME="${HIVERELAY_RELEASE_SIGNING_KEY_NAME:-tag-signing-key}"
# Env var that `keyvault exec --scope hiverelay-release` produces from the key
# named 'tag-signing-key' (namespace stripped, upper-snake): TAG_SIGNING_KEY.
KEY_ENV_VAR="TAG_SIGNING_KEY"
SIGNER_EMAIL="${HIVERELAY_RELEASE_SIGNER_EMAIL:-$(git -C "$REPO_ROOT" config user.email 2>/dev/null || true)}"
GITHUB_REPO="${HIVERELAY_GITHUB_REPO:-bigdestiny2/P2P-Hiverelay}"

log()  { printf '\033[36mrelease:\033[0m %s\n' "$*" >&2; }
warn() { printf '\033[33mrelease: WARNING:\033[0m %s\n' "$*" >&2; }
die()  { printf '\033[31mrelease: ERROR:\033[0m %s\n' "$*" >&2; exit 1; }

need() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"; }

# Any secret material written to disk is tracked here and removed on EVERY exit
# path — success, error under `set -e`, or interrupt. A function-local trap would
# miss the errexit path and strand a private key in the temp dir.
KEYFILE=""; SETUP_TMP=""
cleanup() {
  [ -n "${KEYFILE:-}" ] && rm -f "$KEYFILE" 2>/dev/null || true
  [ -n "${SETUP_TMP:-}" ] && rm -rf "$SETUP_TMP" 2>/dev/null || true
  return 0
}
trap cleanup EXIT INT TERM

# Retrieve the release signing PRIVATE key material, in priority order.
# NEVER echoed. Emits the key to stdout for the caller to redirect to a 0600 file.
get_signing_key() {
  if [ -n "${!KEY_ENV_VAR:-}" ]; then printf '%s' "${!KEY_ENV_VAR}"; return 0; fi
  if [ -n "${SIGNING_KEY_FILE:-}" ]; then cat "$SIGNING_KEY_FILE"; return 0; fi
  if command -v keyvault >/dev/null 2>&1; then
    keyvault get "$VAULT_SCOPE/$VAULT_KEY_NAME" 2>/dev/null && return 0
    die "keyvault has no '$VAULT_SCOPE/$VAULT_KEY_NAME' — run: $0 setup  (and unlock the agent)"
  fi
  die "no signing key available: set $KEY_ENV_VAR (via 'keyvault exec --scope $VAULT_SCOPE'), install keyvault, or set SIGNING_KEY_FILE"
}

# ---------------------------------------------------------------------------
cmd_setup() {
  need ssh-keygen; need keyvault; need awk
  [ -n "$SIGNER_EMAIL" ] || die "no signer email — set HIVERELAY_RELEASE_SIGNER_EMAIL or 'git config user.email'"
  if keyvault get "$VAULT_SCOPE/$VAULT_KEY_NAME" >/dev/null 2>&1; then
    die "'$VAULT_SCOPE/$VAULT_KEY_NAME' already exists in the vault — refusing to overwrite (rotate deliberately with 'keyvault rm' first)"
  fi
  SETUP_TMP="$(mktemp -d)"; chmod 700 "$SETUP_TMP"
  log "generating ed25519 release signing key…"
  ssh-keygen -t ed25519 -N '' -C "hiverelay-release" -f "$SETUP_TMP/relkey" -q
  keyvault set "$VAULT_SCOPE/$VAULT_KEY_NAME" --tags release,signing < "$SETUP_TMP/relkey"
  local keypart; keypart="$(awk '{print $1, $2}' "$SETUP_TMP/relkey.pub")"
  local line="$SIGNER_EMAIL $keypart hiverelay-release"
  touch "$ALLOWED_SIGNERS"
  if grep -qF "$keypart" "$ALLOWED_SIGNERS" 2>/dev/null; then
    log "public key already in $ALLOWED_SIGNERS"
  else
    printf '%s\n' "$line" >> "$ALLOWED_SIGNERS"
    log "added public key to $ALLOWED_SIGNERS"
  fi
  log "PRIVATE key stored in vault '$VAULT_SCOPE/$VAULT_KEY_NAME' (generated in a 0700 temp dir, then removed — never persisted outside the vault)"
  cat >&2 <<EOF

  NEXT:
    1. git add fleet/allowed-signers && git commit -m 'release: add signing key to allowed-signers'
    2. Ship fleet/allowed-signers to every box → /etc/hiverelay/allowed-signers
       (only required from the NEXT release onward; the v0.24.0 hop still runs the
        pre-verify_tag updater — see docs/SUPPLY-CHAIN.md).
    3. Cut a release:  scripts/release.sh cut v0.24.0
EOF
}

# ---------------------------------------------------------------------------
cmd_cut() {
  need git; need node
  local version="" ref="HEAD" prerelease=1 promote_canary=0 assume_yes=0 notes=""
  version="${1:-}"; shift || true
  while [ $# -gt 0 ]; do
    case "$1" in
      --stable)          prerelease=0 ;;
      --promote-canary)  promote_canary=1 ;;
      -y|--yes)          assume_yes=1 ;;
      --notes)           notes="${2:-}"; shift ;;
      -*)                die "unknown flag: $1" ;;
      *)                 ref="$1" ;;
    esac
    shift
  done
  [ -n "$version" ] || die "usage: $0 cut <vX.Y.Z> [<ref>] [--stable] [--promote-canary] [-y]"
  [[ "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] || die "version must look like v0.24.0 (got '$version')"
  [ -n "$SIGNER_EMAIL" ] || die "no signer email — set HIVERELAY_RELEASE_SIGNER_EMAIL or 'git config user.email'"

  git -C "$REPO_ROOT" rev-parse -q --verify "$ref^{commit}" >/dev/null || die "ref not found: $ref"

  # Resolve the exact prospective tagged commit with the same closed-schema
  # validator used by release-surfaces.yml. This happens before signing or
  # pushing, and therefore before any GitHub Release can be created. Enabled
  # gateway releases remain gated by scripts/promote-fleet-channel.mjs.
  local gateway_resolution gateway_enabled requested_channel
  if [ "$prerelease" = 1 ]; then
    requested_channel=none
  else
    requested_channel="${HIVERELAY_RELEASE_CHANNEL:-both}"
  fi
  if ! gateway_resolution="$(node "$REPO_ROOT/scripts/resolve-public-hive-gateway-release.mjs" \
    --repo "$REPO_ROOT" \
    --ref "$ref" \
    --release-target "$version" \
    --requested-channel "$requested_channel" \
    --format github)"; then
    die "public gateway release policy validation failed before tag publication"
  fi
  gateway_enabled="$(printf '%s\n' "$gateway_resolution" | sed -n 's/^public_gateway_enabled=//p')"
  case "$gateway_enabled" in
    true|false) ;;
    *) die "public gateway release resolver returned malformed output" ;;
  esac
  if [ "$gateway_enabled" = true ]; then
    [ "$promote_canary" = 0 ] || die "--promote-canary is disabled for public gateway releases; use the evidence-gated fleet promotion tool"
  fi

  # FOOTGUN GUARD: the fleet updater health-gate compares the tag (minus 'v') to
  # the running /health version, which is package.json's version. A mismatch
  # (e.g. an -rc suffix while package.json says 0.24.0) auto-rolls-back the box.
  local pkgver tagver
  pkgver="$(node -p "require('$REPO_ROOT/package.json').version" 2>/dev/null)" || true
  [ -n "$pkgver" ] || die "cannot read a version from $REPO_ROOT/package.json"
  tagver="${version#v}"
  if [ "$tagver" != "$pkgver" ]; then
    die "tag $version (=$tagver) != package.json version $pkgver. The fleet health-gate compares these; an -rc tag would auto-roll-back. Bump package.json to match, or tag exactly v$pkgver."
  fi
  # FOOTGUN GUARD 2: a non-prerelease tag triggers the secret-gated npm/ecosystem
  # release jobs. Prerelease (default) skips them. Require --stable to opt in.
  if [ "$prerelease" = 0 ]; then
    warn "cutting a STABLE (non-prerelease) release — release-surfaces.yml will run the npm/ecosystem-consumer jobs (needs NPM_TOKEN/ECOSYSTEM_CONSUMER_TOKEN)."
  fi

  if git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/$version" >/dev/null 2>&1; then
    die "tag $version already exists locally — delete it first (git tag -d $version) if re-cutting"
  fi
  local target_sha; target_sha="$(git -C "$REPO_ROOT" rev-parse --short "$ref^{commit}")"

  # Confirm BEFORE signing, so an abort — or a non-interactive stdin — can never
  # strand a signed local tag.
  if [ "$assume_yes" != 1 ]; then
    if [ ! -t 0 ]; then
      die "non-interactive (no TTY on stdin): re-run with -y to confirm — e.g. 'scripts/release.sh cut $version -y'"
    fi
    printf '\033[1mrelease: sign + push %s (%s) to %s as a %s release? [y/N] \033[0m' \
      "$version" "$target_sha" "$GITHUB_REPO" "$([ "$prerelease" = 1 ] && echo prerelease || echo STABLE)" >&2
    local reply=""; read -r reply || reply=""
    [[ "$reply" =~ ^[Yy]$ ]] || die "aborted by user (nothing signed)"
  fi

  # Materialize the signing key to a 0600 temp file (removed on ANY exit path by
  # the global cleanup trap — see KEYFILE above).
  KEYFILE="$(mktemp)"; chmod 600 "$KEYFILE"
  get_signing_key > "$KEYFILE"
  [ -s "$KEYFILE" ] || die "signing key is empty (vault miss or empty env var)"
  # Secret stores (keyvault included) strip the trailing newline, but OpenSSH
  # private keys REQUIRE it — without it ssh-keygen reports 'invalid format',
  # which git surfaces as the confusing 'Couldn't load public key … No such
  # file or directory'. Re-add exactly one newline if the key doesn't end in one.
  if [ -n "$(tail -c1 "$KEYFILE")" ]; then printf '\n' >> "$KEYFILE"; fi

  [ -z "$notes" ] && notes="$version"
  log "signing $version @ $target_sha (SSH, signer $SIGNER_EMAIL)…"
  git -C "$REPO_ROOT" -c gpg.format=ssh -c user.signingkey="$KEYFILE" -c user.email="$SIGNER_EMAIL" \
      tag -s "$version" "$ref" -m "$notes"

  # Self-verify against the shipped allowed-signers BEFORE pushing, so we never
  # publish a tag the fleet's verify_tag would reject — but only when the file
  # has at least one real (non-comment) signer line.
  local signer_lines=0
  if [ -r "$ALLOWED_SIGNERS" ]; then
    signer_lines="$(grep -cvE '^[[:space:]]*(#|$)' "$ALLOWED_SIGNERS" 2>/dev/null || true)"
  fi
  if [ "${signer_lines:-0}" -gt 0 ]; then
    if git -C "$REPO_ROOT" -c gpg.format=ssh -c "gpg.ssh.allowedSignersFile=$ALLOWED_SIGNERS" \
         -c gpg.ssh.program=/usr/bin/ssh-keygen \
         verify-tag "$version" >/dev/null 2>&1; then
      log "self-verify OK — signature trusts against fleet/allowed-signers"
    else
      git -C "$REPO_ROOT" tag -d "$version" >/dev/null 2>&1 || true
      die "self-verify FAILED — this key's public half is not in fleet/allowed-signers. Run: $0 setup"
    fi
  else
    warn "fleet/allowed-signers is empty — skipping self-verify. The fleet's verify_tag will REJECT this tag until you populate + ship it (run: $0 setup)."
  fi

  log "pushing tag…"
  git -C "$REPO_ROOT" push origin "$version"

  if [ "$gateway_enabled" = true ]; then
    log "public gateway release validated; GitHub Release creation is delegated to the manifest-gated release workflow"
  elif command -v gh >/dev/null 2>&1; then
    log "creating GitHub release ($([ "$prerelease" = 1 ] && echo prerelease || echo stable))…"
    # Build the invocation without an empty array — "${arr[@]}" on an empty array
    # is an 'unbound variable' under `set -u` in bash 3.2 (macOS default).
    if [ "$prerelease" = 1 ]; then
      gh release create "$version" --repo "$GITHUB_REPO" --prerelease --title "$version" --notes "$notes" \
        || warn "gh release create failed (tag is pushed; create the release manually)"
    else
      gh release create "$version" --repo "$GITHUB_REPO" --title "$version" --notes "$notes" \
        || warn "gh release create failed (tag is pushed; create the release manually)"
    fi
  else
    warn "gh not installed — tag pushed, but no GitHub release created. Install gh or create it in the UI."
  fi

  if [ "$promote_canary" = 1 ]; then
    log "promoting fleet canary channel → ${version}…"
    node -e '
      const fs=require("fs"), p=process.argv[1], v=process.argv[2];
      const j=JSON.parse(fs.readFileSync(p,"utf8")); j.canary=v;
      fs.writeFileSync(p, JSON.stringify(j,null,2)+"\n");
    ' "$REPO_ROOT/fleet/channels.json" "$version"
    git -C "$REPO_ROOT" add fleet/channels.json
    git -C "$REPO_ROOT" commit -m "fleet: promote canary → ${version}" >/dev/null
    git -C "$REPO_ROOT" push origin HEAD
    log "canary promoted — the canary tier pulls ${version} on its next updater tick (health-gated)."
  fi

  log "done: ${version} released$([ "$promote_canary" = 1 ] && echo ' + canary promoted')."
}

# ---------------------------------------------------------------------------
cmd_status() {
  printf 'git:            %s\n' "$(git --version 2>/dev/null || echo MISSING)"
  printf 'gpg:            %s\n' "$(command -v gpg >/dev/null 2>&1 && gpg --version | head -1 || echo 'absent (fine — SSH signing used)')"
  printf 'gh:             %s\n' "$(command -v gh >/dev/null 2>&1 && echo present || echo 'absent (release step skipped)')"
  printf 'keyvault:       %s\n' "$(command -v keyvault >/dev/null 2>&1 && (keyvault status 2>/dev/null | head -1 || echo installed) || echo absent)"
  printf 'signer email:   %s\n' "${SIGNER_EMAIL:-<unset>}"
  if command -v keyvault >/dev/null 2>&1 && keyvault get "$VAULT_SCOPE/$VAULT_KEY_NAME" >/dev/null 2>&1; then
    printf 'signing key:    present in vault (%s/%s)\n' "$VAULT_SCOPE" "$VAULT_KEY_NAME"
  else
    printf 'signing key:    NOT in vault — run: %s setup\n' "$0"
  fi
  local as_n=0; [ -r "$ALLOWED_SIGNERS" ] && as_n="$(grep -cvE '^[[:space:]]*(#|$)' "$ALLOWED_SIGNERS" 2>/dev/null || true)"
  printf 'allowed-signers:%s\n' "$([ "${as_n:-0}" -gt 0 ] && echo " ${as_n} signer(s)" || echo ' none — run: setup')"
  printf 'package.json:    v%s (a fleet tag MUST be exactly this)\n' "$(node -p "require('$REPO_ROOT/package.json').version" 2>/dev/null || echo '?')"
}

main() {
  local sub="${1:-}"; shift || true
  case "$sub" in
    setup)  cmd_setup "$@" ;;
    cut)    cmd_cut "$@" ;;
    status) cmd_status "$@" ;;
    ''|-h|--help|help)
      sed -n '2,40p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' ;;
    *) die "unknown subcommand '$sub' (try: setup | cut | status)" ;;
  esac
}
main "$@"