publish-outdated-packages.sh rewritten: - Manifest-based change detection (no registry tarball downloads) - Single pack per package (not double-pack for check+publish) - Deterministic content hash: normalizes version, publishConfig, and @bytelyst/* dep versions (workspace:* resolution noise) - Single metadata fetch per package (cached in-process) - Fixed .npmrc overwrite bug that broke auth during publish phase - npm_clean() helper strips all proxy env vars uniformly release-packages.sh fixed: - .npmrc now includes scoped registry + proxy=false for corp - Unified corp/home publish path (no duplicated code) - version_on_registry() uses proxy-stripped env - Registry credential check uses proxy-stripped env CI workflow: switched to publish-outdated-packages.sh --skip-build
438 lines
15 KiB
Bash
Executable File
438 lines
15 KiB
Bash
Executable File
#!/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))
|