#!/usr/bin/env bash # # scripts/release.sh — scripted release for vernonkeenan/lib (ADR-KV-014). # # Replaces the manual "Update Procedure" in README.md (three web UIs + a # go-swagger CLI installed nowhere) with a single, repeatable script driven # through `make`. See README.md's "Release Procedure" section for the # operator-facing walkthrough. # # Subcommands: # install-swagger Install the pinned go-swagger CLI and verify its version. # regen Regenerate api/ clients from the sibling service specs. # release [--dry-run] # Validate, tag, push, publish the GitHub release, and # trigger the Gitea mirror sync. With --dry-run, every # step up to (but not including) push/tag/release/mirror # is performed and the remaining steps are only printed. # # Env: # SWAGGER_VERSION Pinned go-swagger version (default set by Makefile; can override). # GITHUB_REPO owner/repo on GitHub (default vernonkeenan/lib). # GITEA_BASE_URL Gitea API base (default https://code.tnxs.net/api/v1). # GITEA_TOKEN Gitea API token with repo mirror-sync rights. If unset, the # mirror-sync step is skipped with a loud warning — it is # never required to fail the release. There is no # Gitea/tnxs API token variable defined in this workstation's # env files today (checked kv.env, taxnexus.env, telnexus.env, # rosetta.env, wdwai.env); GITEA_TOKEN is a new variable name # introduced by this script, not a rename of an existing one. # GITEA_MIRRORS Space-separated "owner/repo" mirror list (default # "vernonkeenan/lib work/lib"). # set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" SWAGGER_VERSION="${SWAGGER_VERSION:-v0.35.0}" GITHUB_REPO="${GITHUB_REPO:-vernonkeenan/lib}" GITEA_BASE_URL="${GITEA_BASE_URL:-https://code.tnxs.net/api/v1}" GITEA_MIRRORS="${GITEA_MIRRORS:-vernonkeenan/lib work/lib}" # Sibling service repos this ritual pulls specs from, and the go-swagger # generate flags the README's `make swagger` target used to run inline. # Format: "dir:spec_basename:client_name:target_subdir:client_pkg:model_pkg" SERVICES=( "auth:auth-vernonkeenan:auth:api/auth:auth-client:auth-models" "crm:crm-vernonkeenan:crm:api/crm:crm-client:crm-models" "stash:stash-vernonkeenan:stash:api/stash:stash-client:stash-models" "sf-gate:sf-gate-vernonkeenan:sfgate:api/sfgate:sfgate-client:sfgate-models" "research:research-vernonkeenan:research:api/research:research-client:research-models" "members:members-vernonkeenan:members:api/members:members-client:members-models" "plex:plex-vernonkeenan:plex:api/plex:plex-client:plex-models" ) # Host/path substitutions the ritual applied to swagger/external/*.yaml, # keyed by service dir (same mapping the Makefile hardcoded per client). external_host_for() { case "$1" in auth) echo "auth.vernonkeenan.com:8080" ;; crm) echo "crm.vernonkeenan.com:8080" ;; stash) echo "stash.vernonkeenan.com:8080" ;; sf-gate) echo "sf-gate.vernonkeenan.com:8080" ;; research) echo "research.vernonkeenan.com:8080" ;; members) echo "members.vernonkeenan.com:8080" ;; plex) echo "plex.vernonkeenan.com:8080" ;; esac } external_path_for() { case "$1" in auth) echo "/vk/auth/v1" ;; crm) echo "/vk/crm/v1" ;; stash) echo "/vk/stash/v1" ;; sf-gate) echo "/vk/sf-gate/v1" ;; research) echo "/vk/research/v1" ;; members) echo "/vk/members/v1" ;; plex) echo "/vk/plex/v1" ;; esac } log() { printf '[release] %s\n' "$*" >&2; } warn() { printf '\033[33m[release] WARNING: %s\033[0m\n' "$*" >&2; } die() { printf '\033[31m[release] ERROR: %s\033[0m\n' "$*" >&2; exit 1; } usage() { cat >&2 <<'EOF' Usage: scripts/release.sh install-swagger scripts/release.sh regen scripts/release.sh release [--dry-run] See the top of this file for env vars. EOF } # --------------------------------------------------------------------------- # install-swagger # --------------------------------------------------------------------------- swagger_bin() { local gobin gobin="$(go env GOBIN)" if [[ -z "$gobin" ]]; then gobin="$(go env GOPATH)/bin" fi echo "${gobin}/swagger" } cmd_install_swagger() { log "Installing go-swagger ${SWAGGER_VERSION} (pinned; see README.md and Makefile SWAGGER_VERSION)" GOFLAGS=-mod=mod go install "github.com/go-swagger/go-swagger/cmd/swagger@${SWAGGER_VERSION}" local bin bin="$(swagger_bin)" [[ -x "$bin" ]] || die "go install reported success but ${bin} is not executable" local got got="$("$bin" version 2>&1 | head -1)" log "swagger version: ${got}" case "$got" in *"${SWAGGER_VERSION#v}"*) ;; *) die "installed swagger version does not match pinned ${SWAGGER_VERSION} (got: ${got})" ;; esac log "go-swagger ${SWAGGER_VERSION} verified at ${bin}" } # --------------------------------------------------------------------------- # regen # --------------------------------------------------------------------------- check_sibling_specs() { local missing=() local svc dir spec_base rest for svc in "${SERVICES[@]}"; do IFS=':' read -r dir spec_base rest <<<"$svc" local spec_path="${REPO_ROOT}/../${dir}/swagger/${spec_base}.yaml" if [[ ! -f "$spec_path" ]]; then missing+=("$spec_path") fi done if [[ ${#missing[@]} -gt 0 ]]; then die "missing sibling spec(s), cannot regen (need all sibling repos cloned adjacently per README prerequisite): $(printf ' - %s\n' "${missing[@]}")" fi log "all ${#SERVICES[@]} sibling specs present" } cmd_regen() { check_sibling_specs local bin bin="$(swagger_bin)" [[ -x "$bin" ]] || die "swagger CLI not found at ${bin} — run 'make install-swagger' first" cd "$REPO_ROOT" mkdir -p swagger/logs log "copying sibling specs into ./swagger and ./swagger/external" local svc dir spec_base name target pkg model for svc in "${SERVICES[@]}"; do IFS=':' read -r dir spec_base name target pkg model <<<"$svc" cp "../${dir}/swagger/${spec_base}.yaml" "./swagger/" cp "../${dir}/swagger/${spec_base}.yaml" "./swagger/external/" done log "removing generated api/ tree" rm -rf api for svc in "${SERVICES[@]}"; do IFS=':' read -r dir spec_base name target pkg model <<<"$svc" log "generating ${name} client -> ${target}" mkdir -p "$target" "$bin" generate client \ --log-output="./swagger/logs/generate-${name}-client.log" \ --copyright-file=./build/COPYRIGHT \ --name="${name}" \ --spec="./swagger/${spec_base}.yaml" \ --target="${target}" \ --client-package="${pkg}" \ --model-package="${model}" \ --principal=app.User local host path ext="./swagger/external/${spec_base}.yaml" host="$(external_host_for "$dir")" path="$(external_path_for "$dir")" sed -i 's|"http"|"https"|g' "$ext" sed -i "s|${host}|gw.tnxs.net|g" "$ext" sed -i "s|\"/v1\"|\"${path}\"|g" "$ext" done log "regen complete for: $(printf '%s ' "${SERVICES[@]%%:*}")" # Best-effort publish of the external swagger docs, exactly as the manual # ritual's last step did. Never fails the regen — CI/dev boxes typically # have no SSH access to whm.noc.tnxs.net. if command -v scp >/dev/null 2>&1; then log "attempting best-effort scp of swagger/external/*.yaml to whm.noc.tnxs.net (skip on failure)" if ! scp -o ConnectTimeout=5 -o BatchMode=yes \ ./swagger/external/*.yaml root@whm.noc.tnxs.net:/home/taxassets/www/swagger/external 2>/dev/null; then warn "scp to whm.noc.tnxs.net failed or is unreachable from this host — skipped (not fatal)." warn "If external swagger docs need publishing, run the scp manually from a host with access." fi fi log "sanity build (non-fatal; real gate is CI on the regen PR)" if ! go build ./... 2>&1; then warn "generated code does not build. Known cause: any sibling spec operation missing a top-level" warn "'tags:' entry lands in a fallback 'operations' package whose import path in" warn "_client/_client.go does not respect --client-package across at least go-swagger" warn "v0.33.2-v0.35.0 (reproduced on all four during this tooling's own verification). As of this" warn "writing that's sf-gate-vernonkeenan.yaml's 'PUT /researchprojectservices' (no tags:). Fix at" warn "the source (add a tags: entry in the sf-gate repo's spec) rather than hand-patching generated" warn "code, since the next regen would just reintroduce it." fi log "review 'git status' / 'git diff' and commit the regen output as its own PR before running 'make release'." } # --------------------------------------------------------------------------- # release # --------------------------------------------------------------------------- validate_semver() { local v="$1" [[ "$v" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || \ die "VERSION '${v}' is not a valid semver tag (expected vMAJOR.MINOR.PATCH, e.g. v0.7.5)" } latest_tag() { git -C "$REPO_ROOT" tag --list 'v*' | sort -V | tail -1 } check_monotonic() { local new="$1" latest="$2" [[ -n "$latest" ]] || { log "no existing v* tags found; ${new} will be the first"; return; } local top top="$(printf '%s\n%s\n' "$latest" "$new" | sort -V | tail -1)" if [[ "$new" == "$latest" ]]; then die "VERSION ${new} already exists as a tag" fi if [[ "$top" != "$new" ]]; then die "VERSION ${new} is not greater than latest tag ${latest} (releases must be monotonically increasing)" fi log "monotonicity OK: ${latest} -> ${new}" } cmd_release() { local version="${1:-}" local dry_run="false" shift || true while [[ $# -gt 0 ]]; do case "$1" in --dry-run) dry_run="true" ;; *) die "unknown release argument: $1" ;; esac shift done [[ -n "$version" ]] || die "usage: scripts/release.sh release [--dry-run] (or: make release VERSION=v0.7.5)" validate_semver "$version" cd "$REPO_ROOT" log "fetching tags from origin" git fetch --tags --quiet origin local latest latest="$(latest_tag)" check_monotonic "$version" "$latest" local branch branch="$(git rev-parse --abbrev-ref HEAD)" [[ "$branch" == "main" ]] || die "must be on 'main' to release (currently on '${branch}'); main+tags replaced branch-per-version (ADR-KV-014)" if [[ -n "$(git status --porcelain)" ]]; then die "working tree is not clean; commit or stash changes before releasing. (Regen output belongs in its own PR, reviewed and merged to main *before* running 'make release'.)" fi git fetch origin main --quiet local head_sha origin_sha head_sha="$(git rev-parse HEAD)" origin_sha="$(git rev-parse origin/main)" if [[ "$head_sha" != "$origin_sha" ]]; then die "local main (${head_sha:0:12}) does not match origin/main (${origin_sha:0:12}); pull/push so the tag lands on a commit that's actually on GitHub main" fi log "sanity build/vet before tagging ${version} at ${head_sha:0:12}" go build ./... go vet ./... log "About to release ${version}:" log " - annotated tag ${version} @ ${head_sha:0:12} on ${GITHUB_REPO}" log " - push tag to origin" log " - gh release create ${version} --repo ${GITHUB_REPO} --generate-notes" log " - Gitea mirror-sync: ${GITEA_MIRRORS}" if [[ "$dry_run" == "true" ]]; then log "DRY RUN: stopping before tag/push/release/mirror-sync. Nothing was written." return 0 fi log "creating annotated tag ${version}" git tag -a "$version" -m "Release ${version}" log "pushing tag ${version} to origin" git push origin "$version" log "publishing GitHub release ${version}" gh release create "$version" \ --repo "$GITHUB_REPO" \ --title "$version" \ --generate-notes gitea_mirror_sync } gitea_mirror_sync() { if [[ -z "${GITEA_TOKEN:-}" ]]; then warn "GITEA_TOKEN is not set — skipping Gitea mirror-sync for: ${GITEA_MIRRORS}" warn "Run manually later: curl -sS -X POST -H \"Authorization: token \$GITEA_TOKEN\" ${GITEA_BASE_URL}/repos///mirror-sync" return 0 fi local mirror owner repo status for mirror in $GITEA_MIRRORS; do owner="${mirror%%/*}" repo="${mirror##*/}" log "Gitea mirror-sync: ${mirror}" status="$(curl -sS -o /dev/null -w '%{http_code}' -X POST \ -H "Authorization: token ${GITEA_TOKEN}" \ "${GITEA_BASE_URL}/repos/${owner}/${repo}/mirror-sync")" if [[ "$status" != "200" ]]; then warn "mirror-sync for ${mirror} returned HTTP ${status} (expected 200) — check manually at https://code.tnxs.net/${mirror}/settings" else log "mirror-sync triggered for ${mirror} (HTTP 200; sync happens async on the Gitea side)" fi done } # --------------------------------------------------------------------------- # main # --------------------------------------------------------------------------- main() { local cmd="${1:-}" shift || true case "$cmd" in install-swagger) cmd_install_swagger "$@" ;; regen) cmd_regen "$@" ;; release) cmd_release "$@" ;; *) usage; exit 1 ;; esac } main "$@"