#!/usr/bin/env bash # release-packages.sh — Version-bump + publish @bytelyst/* packages to Gitea npm registry # # Usage: # ./scripts/gitea/release-packages.sh # apply pending changesets + publish missing/outdated packages # ./scripts/gitea/release-packages.sh --patch # auto-bump all packages (patch) + publish # ./scripts/gitea/release-packages.sh --minor # auto-bump all packages (minor) + publish # ./scripts/gitea/release-packages.sh --major # auto-bump all packages (major) + publish # ./scripts/gitea/release-packages.sh --dry-run # show what would be published, no side effects # # Required env: # GITEA_NPM_TOKEN — auth token for the Gitea npm registry (or ~/.gitea_npm_token file) # # Optional env: # GITEA_NPM_REGISTRY_URL — override auto-detected registry URL # # Network handling (automatic via NETWORK env var set by switch-network.sh): # NETWORK=corp → localhost:3300 (SSH tunnel to Azure VM, proxy env stripped) # NETWORK=home → Azure VM directly (gitea.bytelyst.com or ~/.gitea_vm_host) set -euo pipefail # ── Config ───────────────────────────────────────────────────────────────────── REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" # ── Network-aware Gitea resolution ───────────────────────────────────────────── # Matches the pattern in publish-outdated-packages.sh NETWORK_MODE="${NETWORK:-home}" if [ "$NETWORK_MODE" = "corp" ]; then GITEA_HOST="${GITEA_NPM_HOST:-localhost}" GITEA_PORT="${GITEA_NPM_PORT:-3300}" GITEA_BASE="http://${GITEA_HOST}:${GITEA_PORT}" IS_CORP=true else if [ -n "${GITEA_NPM_HOST:-}" ] && [ "${GITEA_NPM_HOST}" != "localhost" ]; then GITEA_HOST="$GITEA_NPM_HOST" elif [ -f "$HOME/.gitea_vm_host" ]; then GITEA_HOST="$(cat "$HOME/.gitea_vm_host")" else GITEA_HOST="gitea.bytelyst.com" fi GITEA_PORT="${GITEA_NPM_PORT:-3300}" GITEA_BASE="http://${GITEA_HOST}:${GITEA_PORT}" IS_CORP=false fi REGISTRY_URL="${GITEA_NPM_REGISTRY_URL:-${GITEA_BASE}/api/packages/learning_ai_user/npm/}" AUTH_TARGET="${REGISTRY_URL#http://}" AUTH_TARGET="${AUTH_TARGET#https://}" TOKEN="${GITEA_NPM_TOKEN:-}" TMP_DIR="${TMPDIR:-/tmp}/bytelyst-release-$$" NPMRC_FILE="$TMP_DIR/.npmrc" # single shared npmrc for all npm calls # Workspace dirs that contain publishable npm packages WORKSPACE_DIRS=("packages" "services" "dashboards") # Native SDKs — not published to npm SKIP_PACKAGES=("swift-platform-sdk" "swift-diagnostics" "kotlin-platform-sdk") # Files/dirs to exclude from release commits GIT_EXCLUDE_PATTERNS=("node-compile-cache" "*.tsbuildinfo") # Parse flags BUMP_TYPE="" DRY_RUN=false for arg in "$@"; do case "$arg" in --patch|--minor|--major) BUMP_TYPE="${arg#--}" ;; --dry-run) DRY_RUN=true ;; *) echo "Unknown argument: $arg"; exit 1 ;; esac done # ── Helpers ──────────────────────────────────────────────────────────────────── log() { echo "▸ $*"; } ok() { echo "✅ $*"; } warn() { echo "⚠️ $*"; } fail() { echo "❌ $*" >&2; exit 1; } info() { echo " $*"; } pkg_field() { node -e " const fs = require('fs'); const pkg = JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); process.stdout.write(String(pkg['${1}'] ?? '')); " "$2" } is_skip_package() { local name="$1" for skip in "${SKIP_PACKAGES[@]}"; do [[ "$name" == *"$skip"* ]] && return 0 done return 1 } # Check registry using shared npmrc (reliable auth, proxy stripped) version_on_registry() { local name="$1" version="$2" (cd "$TMP_DIR" && env \ -u http_proxy -u https_proxy -u HTTP_PROXY -u HTTPS_PROXY \ -u npm_config_proxy -u npm_config_https_proxy \ -u NPM_CONFIG_PROXY -u NPM_CONFIG_HTTPS_PROXY \ -u NPM_CONFIG_REGISTRY -u NPM_CONFIG_STRICT_SSL \ -u NPM_CONFIG_NOPROXY -u NODE_TLS_REJECT_UNAUTHORIZED \ npm view "${name}@${version}" version \ --registry "$REGISTRY_URL" \ --userconfig "$NPMRC_FILE" \ --silent \ 2>/dev/null) || true } # ── Preflight ────────────────────────────────────────────────────────────────── cd "$REPO_ROOT" # Resolve token from file if not in env if [ -z "$TOKEN" ] && [ -f "$HOME/.gitea_npm_token" ]; then TOKEN="$(cat "$HOME/.gitea_npm_token")" fi [ -z "$TOKEN" ] && fail "GITEA_NPM_TOKEN is not set (env var or ~/.gitea_npm_token)" command -v pnpm >/dev/null 2>&1 || fail "pnpm not found in PATH" command -v git >/dev/null 2>&1 || fail "git not found in PATH" command -v node >/dev/null 2>&1 || fail "node not found in PATH" # Create shared tmp dir + npmrc early so version checks work before publishing mkdir -p "$TMP_DIR" trap 'rm -rf "$TMP_DIR"' EXIT { printf '//%s:_authToken=%s\n' "$AUTH_TARGET" "$TOKEN" printf '@bytelyst:registry=%s\n' "$REGISTRY_URL" if [ "$IS_CORP" = true ]; then printf 'proxy=false\nhttps-proxy=false\n' fi } > "$NPMRC_FILE" # Show resolved config log "Network: $NETWORK_MODE ($( [ "$IS_CORP" = true ] && echo "corp — localhost tunnel" || echo "home — Azure VM" ))" info "Registry: $REGISTRY_URL" # Verify token can read from registry log "Verifying registry credentials..." if ! (cd "$TMP_DIR" && env \ -u http_proxy -u https_proxy -u HTTP_PROXY -u HTTPS_PROXY \ -u npm_config_proxy -u npm_config_https_proxy \ -u NPM_CONFIG_PROXY -u NPM_CONFIG_HTTPS_PROXY \ -u NPM_CONFIG_REGISTRY -u NPM_CONFIG_STRICT_SSL \ -u NPM_CONFIG_NOPROXY -u NODE_TLS_REJECT_UNAUTHORIZED \ npm view "@bytelyst/errors" version \ --registry "$REGISTRY_URL" \ --userconfig "$NPMRC_FILE" \ --silent 2>/dev/null); then fail "Registry auth failed — check GITEA_NPM_TOKEN has read+write:package scope" fi ok "Registry credentials verified" [ "$DRY_RUN" = true ] && log "Dry-run mode — no packages will be published or committed" # ── Phase 1: Pull-rebase from origin main ───────────────────────────────────── log "Rebasing from origin main..." STASHED=false if ! git diff --quiet || ! git diff --cached --quiet; then log "Stashing local changes before rebase..." git stash push -u -m "release.sh: auto-stash before rebase" STASHED=true fi git fetch origin main if ! git rebase origin/main; then [ "$STASHED" = true ] && git stash pop 2>/dev/null || true fail "Rebase failed — resolve conflicts and re-run" fi if [ "$STASHED" = true ]; then log "Restoring stashed changes..." if ! git stash pop; then echo "" warn "Stash pop has conflicts with the rebased code." info "Resolve the conflicts, then run:" info " git add && git stash drop" info " Then re-run: ./scripts/release.sh" exit 1 fi fi ok "Rebased to origin/main ($(git rev-parse --short HEAD))" # ── Phase 2: Install dependencies ───────────────────────────────────────────── log "Installing dependencies..." pnpm install 2>&1 | grep -v "^Progress:" | grep -v "^packages/react-native" | grep -v "WARN deprecated" || true ok "Dependencies installed" # ── Phase 3: Build all packages ─────────────────────────────────────────────── log "Building all packages..." pnpm build 2>&1 | grep -E "Done|Error|error|failed|✓|✗" | grep -v "^packages.*build:" | head -20 || true ok "Build complete" # ── Phase 4: Apply changesets (if any) ──────────────────────────────────────── CHANGESET_FILES="$(find .changeset -maxdepth 1 -name '*.md' ! -name 'README.md' 2>/dev/null | wc -l | tr -d ' ')" if [ -n "$BUMP_TYPE" ]; then log "Creating $BUMP_TYPE changeset for all publishable packages..." if [ "$DRY_RUN" = false ]; then BUMP_PKGS=() for ws_dir in "${WORKSPACE_DIRS[@]}"; do [ -d "$REPO_ROOT/$ws_dir" ] || continue while IFS= read -r -d '' pkg_json; do private="$(pkg_field private "$pkg_json")" [ "$private" = "true" ] && continue name="$(pkg_field name "$pkg_json")" is_skip_package "$name" && continue BUMP_PKGS+=("$name") done < <(find "$REPO_ROOT/$ws_dir" -mindepth 2 -maxdepth 2 -name package.json -print0 | sort -z) done CS_FILE=".changeset/release-$(date +%Y%m%d%H%M%S).md" { echo "---" for pkg in "${BUMP_PKGS[@]}"; do echo "\"$pkg\": $BUMP_TYPE"; done echo "---" echo "" echo "Release: automated $BUMP_TYPE version bump" } > "$CS_FILE" log "Created changeset: $CS_FILE (${#BUMP_PKGS[@]} packages)" CHANGESET_FILES="1" fi fi if [ "$CHANGESET_FILES" -gt 0 ]; then log "Applying $CHANGESET_FILES changeset(s)..." if [ "$DRY_RUN" = false ]; then pnpm changeset version ok "Version bumps applied" log "Rebuilding after version bumps..." pnpm build 2>&1 | grep -E "Done|Error|failed" | head -10 || true ok "Rebuild complete" else log "[dry-run] Would run: pnpm changeset version && pnpm build" fi else log "No pending changesets — skipping version bump" fi # ── Phase 5: Detect and publish outdated/missing packages ───────────────────── PUBLISHED=() SKIPPED=() ALREADY_PUBLISHED=() FAILED=() publish_package() { local pkg_dir="$1" local pkg_json="$pkg_dir/package.json" local name version private_flag name="$(pkg_field name "$pkg_json")" version="$(pkg_field version "$pkg_json")" private_flag="$(pkg_field private "$pkg_json")" # Skip private packages if [ "$private_flag" = "true" ]; then SKIPPED+=("$name@$version (private)") info " ⊘ $name@$version (private)" return fi # Skip native SDKs if is_skip_package "$name"; then SKIPPED+=("$name@$version (native SDK)") info " ⊘ $name@$version (native SDK)" return fi # Skip packages with no version if [ -z "$version" ]; then SKIPPED+=("$name (no version)") warn " ⊘ $name — no version field" return fi # Check registry (uses shared npmrc — reliable auth) local remote_version remote_version="$(version_on_registry "$name" "$version")" if [ -n "$remote_version" ]; then ALREADY_PUBLISHED+=("$name@$version") info " ✓ $name@$version (up-to-date)" return fi # Not on registry — needs publishing if [ "$DRY_RUN" = true ]; then log " → [dry-run] would publish: $name@$version" PUBLISHED+=("$name@$version") return fi log " → Publishing $name@$version..." local safe_name work_dir packed_tgz final_tgz safe_name="${name//@/}" safe_name="${safe_name//\//-}" work_dir="$TMP_DIR/$safe_name-$version" rm -rf "$work_dir" mkdir -p "$work_dir" # Pack with pnpm if ! (cd "$pkg_dir" && pnpm pack --pack-destination "$work_dir" 2>&1 | grep -v "^Wrote\|^packing"); then warn " ✗ $name@$version — pack failed" FAILED+=("$name@$version (pack failed)") return fi packed_tgz="$(find "$work_dir" -maxdepth 1 -name '*.tgz' | head -1)" if [ -z "$packed_tgz" ]; then warn " ✗ $name@$version — no tarball after pack" FAILED+=("$name@$version (pack failed)") return fi # Repack with npm for Gitea compatibility mkdir -p "$work_dir/unpacked" tar -xzf "$packed_tgz" -C "$work_dir/unpacked" 2>/dev/null if ! (cd "$work_dir/unpacked/package" && npm pack --pack-destination "$work_dir" 2>/dev/null); then warn " ✗ $name@$version — repack failed" FAILED+=("$name@$version (repack failed)") return fi final_tgz="$(find "$work_dir" -maxdepth 1 -name '*.tgz' | sort | tail -1)" if [ -z "$final_tgz" ]; then warn " ✗ $name@$version — no tarball after repack" FAILED+=("$name@$version (repack failed)") return fi # Publish using shared npmrc (strip proxy env so npm reaches localhost/VM directly) local publish_log="$work_dir/publish.log" local publish_ok=false if (cd "$TMP_DIR" && env \ -u http_proxy -u https_proxy -u HTTP_PROXY -u HTTPS_PROXY \ -u npm_config_proxy -u npm_config_https_proxy \ -u NPM_CONFIG_PROXY -u NPM_CONFIG_HTTPS_PROXY \ -u NPM_CONFIG_REGISTRY -u NPM_CONFIG_STRICT_SSL \ -u NPM_CONFIG_NOPROXY -u NODE_TLS_REJECT_UNAUTHORIZED \ npm publish "$final_tgz" \ --registry "$REGISTRY_URL" \ --userconfig "$NPMRC_FILE" \ --silent 2>"$publish_log"); then publish_ok=true fi if [ "$publish_ok" = true ]; then ok " ✅ $name@$version published" PUBLISHED+=("$name@$version") else local err err="$(cat "$publish_log" | grep -v '^npm notice' | head -3)" warn " ✗ $name@$version — publish failed: $err" FAILED+=("$name@$version (publish error)") fi } log "Scanning workspace packages..." echo "" for ws_dir in "${WORKSPACE_DIRS[@]}"; do [ -d "$REPO_ROOT/$ws_dir" ] || continue log "[$ws_dir]" while IFS= read -r -d '' pkg_json; do publish_package "$(dirname "$pkg_json")" done < <(find "$REPO_ROOT/$ws_dir" -mindepth 2 -maxdepth 2 -name package.json -print0 | sort -z) done echo "" # ── Phase 6: Commit and push ────────────────────────────────────────────────── if [ "$DRY_RUN" = false ]; then # Exclude generated/cache dirs from commits for pattern in "${GIT_EXCLUDE_PATTERNS[@]}"; do git restore --staged "**/$pattern" 2>/dev/null || true done # Stage only tracked files + known release artifacts (not caches) git add \ "packages/*/package.json" \ "services/*/package.json" \ "dashboards/*/package.json" \ "package.json" \ "pnpm-lock.yaml" \ ".changeset/" \ "CHANGELOG.md" 2>/dev/null || true if ! git diff --cached --quiet; then log "Committing version bumps..." git commit -m "chore: release version bumps [skip ci]" log "Pushing to origin main..." git push origin main ok "Pushed to origin/main" else log "No version bump changes to commit" fi fi # ── Summary ──────────────────────────────────────────────────────────────────── TOTAL_PKGS=$(( ${#PUBLISHED[@]} + ${#ALREADY_PUBLISHED[@]} + ${#SKIPPED[@]} + ${#FAILED[@]} )) echo "════════════════════════════════════════" echo " Release Summary (${TOTAL_PKGS} packages scanned)" echo "════════════════════════════════════════" if [ ${#PUBLISHED[@]} -gt 0 ]; then echo "" echo "Published (${#PUBLISHED[@]}):" for p in "${PUBLISHED[@]}"; do echo " ✅ $p"; done fi if [ ${#FAILED[@]} -gt 0 ]; then echo "" echo "Failed (${#FAILED[@]}):" for p in "${FAILED[@]}"; do echo " ❌ $p"; done fi if [ ${#ALREADY_PUBLISHED[@]} -gt 0 ]; then echo "" echo "Already up-to-date (${#ALREADY_PUBLISHED[@]}):" for p in "${ALREADY_PUBLISHED[@]}"; do echo " · $p"; done fi if [ ${#SKIPPED[@]} -gt 0 ]; then echo "" echo "Skipped (${#SKIPPED[@]}):" for p in "${SKIPPED[@]}"; do echo " ⊘ $p"; done fi echo "" [ "$DRY_RUN" = true ] && echo " (dry-run — no changes made)" # Exit non-zero if any publishes failed if [ ${#FAILED[@]} -gt 0 ]; then echo " ⚠️ ${#FAILED[@]} package(s) failed to publish — see above" exit 1 fi echo "════════════════════════════════════════"