#!/usr/bin/env bash # release.sh — Full release pipeline for learning_ai_common_plat # # Usage: # ./scripts/release.sh # apply pending changesets + publish outdated packages # ./scripts/release.sh --patch # auto-bump all packages (patch) + publish # ./scripts/release.sh --minor # auto-bump all packages (minor) + publish # ./scripts/release.sh --major # auto-bump all packages (major) + publish # ./scripts/release.sh --dry-run # show what would be published, no side effects # # Required env: # GITEA_NPM_TOKEN — auth token for the Gitea npm registry # # Optional env: # GITEA_NPM_REGISTRY_URL — defaults to https://gitea.bytelyst.com/api/packages/ByteLyst/npm/ set -euo pipefail # ── Config ───────────────────────────────────────────────────────────────────── REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" REGISTRY_URL="${GITEA_NPM_REGISTRY_URL:-https://gitea.bytelyst.com/api/packages/ByteLyst/npm/}" AUTH_TARGET="${REGISTRY_URL#http://}" AUTH_TARGET="${AUTH_TARGET#https://}" TOKEN="${GITEA_NPM_TOKEN:-}" TMP_DIR="${TMPDIR:-/tmp}/bytelyst-release-$$" # 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") # 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; } 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 if [[ "$name" == *"$skip"* ]]; then return 0; fi done return 1 } version_on_registry() { local name="$1" version="$2" npm view "${name}@${version}" version \ --registry "$REGISTRY_URL" \ "--//${AUTH_TARGET}:_authToken=${TOKEN}" \ 2>/dev/null || true } # ── Preflight checks ─────────────────────────────────────────────────────────── cd "$REPO_ROOT" [ -z "$TOKEN" ] && fail "GITEA_NPM_TOKEN is not set" 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" if [ "$DRY_RUN" = true ]; then log "Dry-run mode — no packages will be published, no commits will be made" fi # ── 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 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." echo " Resolve the conflicts above, then run:" echo " git add " echo " git stash drop" echo " 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 --frozen-lockfile ok "Dependencies installed" # ── Phase 3: Build all packages ─────────────────────────────────────────────── log "Building all packages..." pnpm build 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 # Collect all non-private workspace package names 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 # Write a synthetic changeset file 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" 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 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 packages ───────────────────────────── rm -rf "$TMP_DIR" mkdir -p "$TMP_DIR" trap 'rm -rf "$TMP_DIR"' EXIT PUBLISHED=() SKIPPED=() ALREADY_PUBLISHED=() publish_package() { local pkg_dir="$1" local pkg_json="$pkg_dir/package.json" local name version safe_name work_dir packed_tgz final_tgz name="$(pkg_field name "$pkg_json")" version="$(pkg_field version "$pkg_json")" local private_flag private_flag="$(pkg_field private "$pkg_json")" # Skip private packages if [ "$private_flag" = "true" ]; then SKIPPED+=("$name@$version (private)") return fi # Skip native SDKs if is_skip_package "$name"; then SKIPPED+=("$name@$version (native SDK)") return fi # Skip packages with no version [ -z "$version" ] && { SKIPPED+=("$name (no version)"); return; } # Check if this exact version is already on the registry local remote_version remote_version="$(version_on_registry "$name" "$version")" if [ -n "$remote_version" ]; then ALREADY_PUBLISHED+=("$name@$version") return fi if [ "$DRY_RUN" = true ]; then log "[dry-run] Would publish: $name@$version" PUBLISHED+=("$name@$version") return fi log "Publishing $name@$version..." 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, repack with npm (Gitea registry compatibility) (cd "$pkg_dir" && pnpm pack --pack-destination "$work_dir" >/dev/null) packed_tgz="$(find "$work_dir" -maxdepth 1 -name '*.tgz' | head -1)" [ -z "$packed_tgz" ] && { warn "Pack failed for $name@$version — skipping"; SKIPPED+=("$name@$version (pack failed)"); return; } mkdir -p "$work_dir/unpacked" tar -xzf "$packed_tgz" -C "$work_dir/unpacked" (cd "$work_dir/unpacked/package" && npm pack --pack-destination "$work_dir" >/dev/null) final_tgz="$(find "$work_dir" -maxdepth 1 -name '*.tgz' | sort | tail -1)" [ -z "$final_tgz" ] && { warn "Repack failed for $name@$version — skipping"; SKIPPED+=("$name@$version (repack failed)"); return; } if npm publish "$final_tgz" \ --registry "$REGISTRY_URL" \ "--//${AUTH_TARGET}:_authToken=${TOKEN}" 2>&1; then ok "$name@$version published" PUBLISHED+=("$name@$version") else warn "Publish failed for $name@$version" SKIPPED+=("$name@$version (publish error)") fi } log "Scanning workspace packages..." for ws_dir in "${WORKSPACE_DIRS[@]}"; do [ -d "$REPO_ROOT/$ws_dir" ] || continue 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 # ── Phase 6: Commit and push version bumps ──────────────────────────────────── if [ "$DRY_RUN" = false ]; then if ! git diff --quiet HEAD 2>/dev/null || [ -n "$(git status --porcelain)" ]; then log "Committing version bumps..." git add -A 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 git changes to commit" fi fi # ── Summary ──────────────────────────────────────────────────────────────────── echo "" echo "════════════════════════════════════════" echo " Release Summary" echo "════════════════════════════════════════" if [ ${#PUBLISHED[@]} -gt 0 ]; then echo "" echo "Published (${#PUBLISHED[@]}):" for p in "${PUBLISHED[@]}"; 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)" echo "════════════════════════════════════════"