feat: replace manual release ritual with `make release` (ADR-KV-014)
The README's "Update Procedure" required three web UIs (GitHub branch
defaults, GitHub releases, Gitea "Synchronize Now") and a go-swagger CLI
installed nowhere. Now that lib is main+tags instead of branch-per-version,
replace it with scripts/release.sh driven through the Makefile:
- `make install-swagger` installs a pinned go-swagger (v0.35.0) and
verifies the version.
- `make regen` copies the sibling service specs and regenerates api/
clients exactly as the old `make swagger` ritual did, failing fast if
a sibling spec is missing. Regen output is reviewed/committed as its
own PR, same as any other change.
- `make release VERSION=vX.Y.Z` / `make release-dry-run VERSION=vX.Y.Z`
validate semver + monotonicity, require a clean main checkout synced
with origin/main, tag, push, `gh release create`, and POST to the
Gitea mirror-sync API for the vernonkeenan/lib and work/lib mirrors
(skipped with a loud warning if GITEA_TOKEN is unset).
Also scope the CI push trigger to `main` + `v*` tags instead of every
branch, so PRs (which already run via the `pull_request` trigger) stop
double-running.
Verified locally: bash -n + shellcheck clean; install-swagger into a
temp GOBIN across go-swagger v0.33.2-v0.35.0; semver/monotonicity/
tag-exists/branch/origin-sync validation failure paths; a full regen
against the real sibling specs in a scratch dir (never the checked-out
api/swagger). That regen surfaced a real go-swagger limitation --
documented in the README and as a warning in the script -- reproduced
across all four installable versions: an untagged operation in
sf-gate-vernonkeenan.yaml produces a client that doesn't compile.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 06:49:23 +00:00
#!/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 <VERSION> [--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 <VERSION> [ --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 "
2026-07-22 18:12:56 +00:00
local got version_output
version_output = " $( " $bin " version 2>& 1) "
got = " ${ version_output %% $'\n' * } "
feat: replace manual release ritual with `make release` (ADR-KV-014)
The README's "Update Procedure" required three web UIs (GitHub branch
defaults, GitHub releases, Gitea "Synchronize Now") and a go-swagger CLI
installed nowhere. Now that lib is main+tags instead of branch-per-version,
replace it with scripts/release.sh driven through the Makefile:
- `make install-swagger` installs a pinned go-swagger (v0.35.0) and
verifies the version.
- `make regen` copies the sibling service specs and regenerates api/
clients exactly as the old `make swagger` ritual did, failing fast if
a sibling spec is missing. Regen output is reviewed/committed as its
own PR, same as any other change.
- `make release VERSION=vX.Y.Z` / `make release-dry-run VERSION=vX.Y.Z`
validate semver + monotonicity, require a clean main checkout synced
with origin/main, tag, push, `gh release create`, and POST to the
Gitea mirror-sync API for the vernonkeenan/lib and work/lib mirrors
(skipped with a loud warning if GITEA_TOKEN is unset).
Also scope the CI push trigger to `main` + `v*` tags instead of every
branch, so PRs (which already run via the `pull_request` trigger) stop
double-running.
Verified locally: bash -n + shellcheck clean; install-swagger into a
temp GOBIN across go-swagger v0.33.2-v0.35.0; semver/monotonicity/
tag-exists/branch/origin-sync validation failure paths; a full regen
against the real sibling specs in a scratch dir (never the checked-out
api/swagger). That regen surfaced a real go-swagger limitation --
documented in the README and as a warning in the script -- reproduced
across all four installable versions: an untagged operation in
sf-gate-vernonkeenan.yaml produces a client that doesn't compile.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 06:49:23 +00:00
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 "<name>_client/<name>_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 <VERSION> [--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/<owner>/<repo>/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 " $@ "