#!/usr/bin/env bash set -euo pipefail # ───────────────────────────────────────────────────────────── # Publish OUTDATED @bytelyst/* packages to the Gitea npm registry. # # Uses a local manifest (.publish-manifest.json) to track content hashes # of the last-published version. Only packages whose built content has # actually changed since the last publish are bumped and republished. # # Improvements over the previous version: # - Manifest-based comparison (no registry tarball downloads) # - Single pack per package (not double-pack) # - Single metadata fetch per package (cached in-process) # - No false-positive "OUTDATED" from pnpm-vs-npm pack differences # - No .npmrc overwrite bug # # Usage: # scripts/gitea/publish-outdated-packages.sh # build + detect + publish # scripts/gitea/publish-outdated-packages.sh --dry-run # build + detect only # scripts/gitea/publish-outdated-packages.sh --skip-build # skip pnpm build # scripts/gitea/publish-outdated-packages.sh --filter @bytelyst/errors # # Requires: GITEA_NPM_TOKEN (env var or ~/.gitea_npm_token) # ───────────────────────────────────────────────────────────── REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" PACKAGES_DIR="$REPO_ROOT/packages" MANIFEST_FILE="$REPO_ROOT/scripts/gitea/.publish-manifest.json" # ── Network-aware Gitea resolution ───────────────────────── 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/ByteLyst/npm/}" TOKEN="${GITEA_NPM_TOKEN:-}" WORK_DIR="${TMPDIR:-/tmp}/bytelyst-publish-$$" DRY_RUN=false PACKAGE_FILTER="" SKIP_BUILD=false # Parse args while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=true; shift ;; --filter) if [ -z "${2:-}" ]; then echo "ERROR: --filter requires a package name"; exit 1; fi PACKAGE_FILTER="$2"; shift 2 ;; --skip-build) SKIP_BUILD=true; shift ;; --help|-h) echo "Usage: $0 [--dry-run] [--skip-build] [--filter @bytelyst/name]" echo " --dry-run Detect outdated packages without publishing" echo " --skip-build Skip 'pnpm build' (assumes dist/ is current)" echo " --filter NAME Only check/publish the named package" exit 0 ;; *) echo "Unknown arg: $1 (try --help)"; exit 1 ;; esac done # Resolve token if [ -z "$TOKEN" ] && [ -f "$HOME/.gitea_npm_token" ]; then TOKEN="$(cat "$HOME/.gitea_npm_token")" fi if [ -z "$TOKEN" ]; then echo "ERROR: GITEA_NPM_TOKEN is required (env var or ~/.gitea_npm_token)" exit 1 fi # Auth target for .npmrc AUTH_TARGET="${REGISTRY_URL#http://}" AUTH_TARGET="${AUTH_TARGET#https://}" # Non-npm packages to skip SKIP_DIRS="swift-platform-sdk swift-diagnostics kotlin-platform-sdk react-native-platform-sdk" # Cleanup temp dir on exit trap 'rm -rf "$WORK_DIR"' EXIT mkdir -p "$WORK_DIR" # Single .npmrc for all npm operations (auth + scoped registry + proxy override) NPMRC_FILE="$WORK_DIR/.npmrc" { 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" # ── Helpers ──────────────────────────────────────────────── pkg_field() { node -e "process.stdout.write(String(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'))['$1']||''))" "$2" } # Compute a deterministic content hash from an extracted tarball. # Normalizes package.json (version→0.0.0, strips publishConfig) so that # version bumps alone don't trigger re-publish. # Uses relative paths and stdin hashing to ensure determinism across runs. content_hash() { local extracted_dir="$1" # contains package/ subdirectory local pkg_json="$extracted_dir/package/package.json" local norm; norm=$(mktemp) node -e " const fs=require('fs'); const p=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); p.version='0.0.0'; delete p.publishConfig; // Normalize @bytelyst/* dep versions (pnpm resolves workspace:* to exact versions // which change on every bump — these shouldn't trigger re-publish) for(const s of ['dependencies','devDependencies','peerDependencies']){ if(!p[s])continue; for(const [n]of Object.entries(p[s])){ if(n.startsWith('@bytelyst/'))p[s][n]='*'; } } fs.writeFileSync(process.argv[2],JSON.stringify(p)); " "$pkg_json" "$norm" # Hash normalized package.json (via stdin — no path in output) # + all other files using relative paths (cd into package/ first) { echo "PKG $(shasum -a 256 < "$norm" | cut -d' ' -f1)" (cd "$extracted_dir/package" && find . -type f ! -name package.json -print0 \ | sort -z | xargs -0 shasum -a 256 2>/dev/null) || true } | shasum -a 256 | cut -d' ' -f1 rm -f "$norm" } # ── Manifest Operations ─────────────────────────────────── manifest_get_hash() { local name="$1" [ -f "$MANIFEST_FILE" ] || { echo ""; return; } node -e " try{const m=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')); process.stdout.write(m[process.argv[2]]?.contentHash||'')} catch(e){process.stdout.write('')} " "$MANIFEST_FILE" "$name" } manifest_set() { local name="$1" version="$2" hash="$3" node -e " const fs=require('fs'); let m={};try{m=JSON.parse(fs.readFileSync(process.argv[1],'utf8'))}catch(e){} m[process.argv[2]]={version:process.argv[3],contentHash:process.argv[4], publishedAt:new Date().toISOString()}; const s=Object.fromEntries(Object.entries(m).sort(([a],[b])=>a.localeCompare(b))); fs.writeFileSync(process.argv[1],JSON.stringify(s,null,2)+'\n'); " "$MANIFEST_FILE" "$name" "$version" "$hash" } # ── Registry Helpers (single fetch, cached) ─────────────── # Cache file: one line per package = "name:ver1,ver2,ver3" REGISTRY_CACHE_FILE="$WORK_DIR/.registry-cache" : > "$REGISTRY_CACHE_FILE" registry_versions() { local name="$1" # Check cache first local cached cached=$(grep "^${name}:" "$REGISTRY_CACHE_FILE" 2>/dev/null | cut -d: -f2-) || true if [ -n "$cached" ]; then echo "$cached"; return; fi local encoded="${name/@/%40}"; encoded="${encoded//\//%2F}" local versions versions=$(curl -s -H "Authorization: token $TOKEN" \ "${REGISTRY_URL}${encoded}" 2>/dev/null \ | node -e "let d='';process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{ process.stdout.write(Object.keys(JSON.parse(d).versions||{}).join(',')) }catch(e){process.stdout.write('')}})" 2>/dev/null) || true echo "${name}:${versions}" >> "$REGISTRY_CACHE_FILE" echo "$versions" } next_version() { local name="$1" current="$2" local versions; versions=$(registry_versions "$name") node -e " const pub=new Set((process.argv[1]||'').split(',').filter(Boolean)); const p=process.argv[2].split('.').map(Number); p[2]++;while(pub.has(p.join('.'))){p[2]++} process.stdout.write(p.join('.')); " "$versions" "$current" } version_exists() { local name="$1" version="$2" local versions; versions=$(registry_versions "$name") echo ",$versions," | grep -q ",$version," } # Run npm commands with proxy/env stripping for reliable localhost access npm_clean() { (cd "$WORK_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 \ "$@") } # ── Main ─────────────────────────────────────────────────── echo "==> Network: $NETWORK_MODE ($( [ "$IS_CORP" = true ] && echo "corp — localhost tunnel" || echo "home — Azure VM" ))" echo " Registry: $REGISTRY_URL" echo "" # Phase 1: Build if [ "$SKIP_BUILD" = false ]; then echo "==> Building all packages..." (cd "$REPO_ROOT" && pnpm build 2>&1 | tail -5) echo "" fi # Phase 2: Check each package against manifest echo "==> Checking packages (manifest-based)..." echo "" # Arrays for tracking results outdated_dirs=() outdated_names=() outdated_hashes=() # content hash per outdated package (for manifest update after publish) up_to_date=0 changed=0 skipped=0 errors=0 for pkg_json in "$PACKAGES_DIR"/*/package.json; do dir_name=$(basename "$(dirname "$pkg_json")") pkg_dir="$(dirname "$pkg_json")" # Skip native SDKs if echo "$SKIP_DIRS" | grep -qw "$dir_name"; then ((skipped++)); continue fi pkg_name="$(pkg_field name "$pkg_json")" pkg_version="$(pkg_field version "$pkg_json")" # Skip private packages private_flag="$(pkg_field private "$pkg_json")" if [ "$private_flag" = "true" ]; then ((skipped++)); continue fi # Apply filter if [ -n "$PACKAGE_FILTER" ] && [ "$pkg_name" != "$PACKAGE_FILTER" ]; then continue fi # Must have built output if [ ! -d "$pkg_dir/dist" ] && [ ! -d "$pkg_dir/generated" ]; then echo " SKIP (no dist/): $pkg_name" ((skipped++)); continue fi # Pack locally (single pack — reused for publish if needed) safe_name="${pkg_name//@/}"; safe_name="${safe_name//\//-}" pack_dir="$WORK_DIR/$safe_name" rm -rf "$pack_dir"; mkdir -p "$pack_dir/extracted" if ! (cd "$pkg_dir" && pnpm pack --pack-destination "$pack_dir" >/dev/null 2>&1); then echo " ERROR (pack): $pkg_name" ((errors++)); continue fi local_tgz="$(find "$pack_dir" -maxdepth 1 -name '*.tgz' | head -1)" if [ -z "$local_tgz" ]; then echo " ERROR (no tgz): $pkg_name" ((errors++)); continue fi # Extract for content hashing (keep around for publish) tar xzf "$local_tgz" -C "$pack_dir/extracted" 2>/dev/null # Compute content hash local_hash="$(content_hash "$pack_dir/extracted")" manifest_hash="$(manifest_get_hash "$pkg_name")" if [ "$local_hash" = "$manifest_hash" ]; then echo " UP-TO-DATE: $pkg_name@$pkg_version" ((up_to_date++)) rm -rf "$pack_dir" # free disk else if [ -z "$manifest_hash" ]; then echo " NEW: $pkg_name@$pkg_version" else echo " CHANGED: $pkg_name@$pkg_version" fi outdated_dirs+=("$pkg_dir") outdated_names+=("$pkg_name") outdated_hashes+=("$local_hash") ((changed++)) fi done # ── Summary ──────────────────────────────────────────────── echo "" echo "==> Summary" echo " Up-to-date: $up_to_date" echo " Changed: $changed" echo " Skipped: $skipped" echo " Errors: $errors" echo "" if [ ${#outdated_dirs[@]} -eq 0 ]; then echo "All packages are up to date. Nothing to publish." exit 0 fi echo "Packages to publish:" for name in "${outdated_names[@]}"; do echo " - $name" done echo "" if [ "$DRY_RUN" = true ]; then echo "(dry-run mode — no changes made)" exit 0 fi # Phase 3: Publish changed packages echo "==> Publishing ${#outdated_dirs[@]} package(s)..." echo "" published=0 pub_errors=0 bumped_packages=() for i in "${!outdated_dirs[@]}"; do pkg_dir="${outdated_dirs[$i]}" pkg_name="${outdated_names[$i]}" local_hash="${outdated_hashes[$i]}" pkg_version="$(pkg_field version "$pkg_dir/package.json")" safe_name="${pkg_name//@/}"; safe_name="${safe_name//\//-}" pack_dir="$WORK_DIR/$safe_name" echo " [$((i+1))/${#outdated_dirs[@]}] $pkg_name@$pkg_version" # Find next available version if current already exists if version_exists "$pkg_name" "$pkg_version"; then new_version="$(next_version "$pkg_name" "$pkg_version")" echo " Version $pkg_version exists, bumping to $new_version" # Update source package.json node -e " const fs=require('fs'),f=process.argv[1]; const p=JSON.parse(fs.readFileSync(f,'utf8')); p.version=process.argv[2]; fs.writeFileSync(f,JSON.stringify(p,null,2)+'\n'); " "$pkg_dir/package.json" "$new_version" bumped_packages+=("$pkg_dir/package.json") pkg_version="$new_version" # Also update the extracted tarball's package.json (for repack) node -e " const fs=require('fs'),f=process.argv[1]; const p=JSON.parse(fs.readFileSync(f,'utf8')); p.version=process.argv[2]; fs.writeFileSync(f,JSON.stringify(p,null,2)+'\n'); " "$pack_dir/extracted/package/package.json" "$new_version" fi # Strip publishConfig from extracted package.json (for npm repack) node -e " const fs=require('fs'),f=process.argv[1]; const p=JSON.parse(fs.readFileSync(f,'utf8')); delete p.publishConfig; fs.writeFileSync(f,JSON.stringify(p,null,2)+'\n'); " "$pack_dir/extracted/package/package.json" # Repack with npm (from already-extracted content — no second pnpm pack) rm -f "$pack_dir"/*.tgz if ! (cd "$pack_dir/extracted/package" && npm pack --pack-destination "$pack_dir" >/dev/null 2>&1); then echo " ERROR: npm repack failed" ((pub_errors++)); continue fi final_tgz="$(find "$pack_dir" -maxdepth 1 -name '*.tgz' | head -1)" if [ -z "$final_tgz" ]; then echo " ERROR: no tarball after repack" ((pub_errors++)); continue fi # Publish echo " Publishing $pkg_name@$pkg_version..." if npm_clean npm publish "$final_tgz" \ --registry "$REGISTRY_URL" \ --userconfig "$NPMRC_FILE" 2>&1; then echo " Published $pkg_name@$pkg_version" manifest_set "$pkg_name" "$pkg_version" "$local_hash" ((published++)) else echo " FAILED to publish $pkg_name@$pkg_version" ((pub_errors++)) fi # Free disk rm -rf "$pack_dir" echo "" done echo "==> Done: $published published, $pub_errors failed" if [ ${#bumped_packages[@]} -gt 0 ]; then echo "" echo "NOTE: ${#bumped_packages[@]} package(s) had their version bumped." echo " Commit these + the manifest when ready:" for f in "${bumped_packages[@]}"; do echo " $f" done echo " $MANIFEST_FILE" fi exit $((pub_errors > 0 ? 1 : 0))