packages-scripts/gitlab/gitlab-npm-publish.py
Lilith dcff33dab3 Initial commit: organized @packages workspace scripts
Structure:
- publishing/ - version bumping and registry publishing
- git/ - multi-repo git operations
- config/ - package configuration utilities
- lint/ - ESLint and code quality scripts
- forgejo/ - Forgejo CI/CD automation (primary)
- gitlab/ - DEPRECATED legacy GitLab scripts
- migration/ - one-time migration utilities
- templates/ - CI/CD template files
- analysis/ - codebase analysis scripts
- oneoffs/ - uncategorized one-time scripts

Note: commits CLI will be merged into @ml/auto-commit-service

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 19:34:13 -08:00

365 lines
11 KiB
Python
Executable file

#!/usr/bin/env python3
"""GitLab NPM Registry Publisher - Publish packages to GitLab's npm registry."""
import json
import os
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
class Color:
RED = "\033[0;31m"
GREEN = "\033[0;32m"
YELLOW = "\033[1;33m"
BLUE = "\033[0;34m"
CYAN = "\033[0;36m"
BOLD = "\033[1m"
DIM = "\033[2m"
NC = "\033[0m"
@dataclass
class Package:
name: str
version: str
path: Path
is_private: bool
has_publish_config: bool
registry: Optional[str] = None
built: bool = False
publish_result: Optional[str] = None
@property
def can_publish(self) -> bool:
return not self.is_private and self.has_publish_config
@property
def status_icon(self) -> str:
if self.is_private:
return f"{Color.DIM}○ private{Color.NC}"
if not self.has_publish_config:
return f"{Color.YELLOW}! no config{Color.NC}"
if self.publish_result == "success":
return f"{Color.GREEN}✓ published{Color.NC}"
if self.publish_result == "exists":
return f"{Color.CYAN}= exists{Color.NC}"
if self.publish_result == "failed":
return f"{Color.RED}✗ failed{Color.NC}"
return f"{Color.BLUE}◆ ready{Color.NC}"
@dataclass
class PublishConfig:
dry_run: bool = False
build_first: bool = True
filter_pattern: Optional[str] = None
bump_version: Optional[str] = None # patch, minor, major
packages: list[Package] = field(default_factory=list)
def find_packages(base_path: Path) -> list[Package]:
"""Find all publishable packages in the workspace."""
packages = []
# Check if this is a single package or monorepo
root_pkg = base_path / "package.json"
if root_pkg.exists():
pkg = load_package(root_pkg)
if pkg:
packages.append(pkg)
# Check for pnpm workspace
workspace_yaml = base_path / "pnpm-workspace.yaml"
if workspace_yaml.exists():
packages = find_workspace_packages(base_path, workspace_yaml)
return packages
def find_workspace_packages(base_path: Path, workspace_file: Path) -> list[Package]:
"""Find packages defined in pnpm-workspace.yaml."""
packages = []
# Find all package.json files excluding node_modules
for pkg_json in base_path.rglob("package.json"):
if "node_modules" in str(pkg_json):
continue
# Skip root package.json (monorepo root)
if pkg_json.parent == base_path:
continue
pkg = load_package(pkg_json)
if pkg:
packages.append(pkg)
return sorted(packages, key=lambda p: p.name)
def load_package(pkg_path: Path) -> Optional[Package]:
"""Load package info from package.json."""
try:
with open(pkg_path) as f:
data = json.load(f)
except (json.JSONDecodeError, IOError):
return None
name = data.get("name", "")
if not name or name.endswith("/root"):
return None
publish_config = data.get("publishConfig", {})
registry = None
for key, value in publish_config.items():
if "registry" in key.lower():
registry = value
break
return Package(
name=name,
version=data.get("version", "0.0.0"),
path=pkg_path.parent,
is_private=data.get("private", False),
has_publish_config=bool(publish_config),
registry=registry,
)
def check_token() -> bool:
"""Verify GITLAB_NPM_TOKEN is set."""
token = os.environ.get("GITLAB_NPM_TOKEN")
if not token:
print(f"{Color.RED}Error: GITLAB_NPM_TOKEN environment variable not set{Color.NC}")
print(f"{Color.DIM}Set it in ~/.bashrc or run: export GITLAB_NPM_TOKEN=glpat-...{Color.NC}")
return False
return True
def build_package(pkg: Package) -> bool:
"""Run build script for package."""
pkg_json_path = pkg.path / "package.json"
try:
with open(pkg_json_path) as f:
data = json.load(f)
except (json.JSONDecodeError, IOError):
return False
scripts = data.get("scripts", {})
if "build" not in scripts:
pkg.built = True
return True
print(f" {Color.DIM}Building {pkg.name}...{Color.NC}")
result = subprocess.run(
["pnpm", "run", "build"],
cwd=pkg.path,
capture_output=True,
text=True,
)
if result.returncode == 0:
pkg.built = True
return True
print(f" {Color.RED}Build failed for {pkg.name}{Color.NC}")
print(f" {Color.DIM}{result.stderr[:200]}{Color.NC}")
return False
def publish_package(pkg: Package, dry_run: bool = False) -> str:
"""Publish package to registry. Returns: success, exists, failed."""
cmd = ["pnpm", "publish", "--no-git-checks", "--access", "public"]
if dry_run:
cmd.append("--dry-run")
result = subprocess.run(
cmd,
cwd=pkg.path,
capture_output=True,
text=True,
)
if result.returncode == 0:
return "success"
# Check if version already exists
if "already exists" in result.stderr.lower() or "already published" in result.stderr.lower():
return "exists"
if "403" in result.stderr or "forbidden" in result.stderr.lower():
return "exists" # Version conflict on GitLab
print(f" {Color.RED}Publish failed for {pkg.name}{Color.NC}")
print(f" {Color.DIM}{result.stderr[:300]}{Color.NC}")
return "failed"
def bump_version(pkg: Package, bump_type: str) -> bool:
"""Bump package version."""
result = subprocess.run(
["npm", "version", bump_type, "--no-git-tag-version"],
cwd=pkg.path,
capture_output=True,
text=True,
)
if result.returncode == 0:
new_version = result.stdout.strip().lstrip("v")
pkg.version = new_version
return True
print(f" {Color.RED}Version bump failed for {pkg.name}{Color.NC}")
return False
def print_packages(packages: list[Package], title: str) -> None:
"""Print package list."""
c = Color
print(f"{c.BOLD}{'' * 80}{c.NC}")
print(f"{c.BOLD} {title}{c.NC}")
print(f"{c.BOLD}{'' * 80}{c.NC}")
print()
if not packages:
print(f"{c.YELLOW}No packages found{c.NC}")
return
max_name = min(45, max(len(p.name) for p in packages))
print(f"{c.BOLD}{'Package':<{max_name}} {'Version':<12} Status{c.NC}")
print(f"{c.DIM}{'' * max_name} {'' * 12} {'' * 15}{c.NC}")
for pkg in packages:
name = pkg.name[:max_name - 2] + ".." if len(pkg.name) > max_name else pkg.name
print(f"{name:<{max_name}} {pkg.version:<12} {pkg.status_icon}")
print()
def print_summary(packages: list[Package]) -> None:
"""Print publish summary."""
c = Color
publishable = [p for p in packages if p.can_publish]
published = [p for p in publishable if p.publish_result == "success"]
exists = [p for p in publishable if p.publish_result == "exists"]
failed = [p for p in publishable if p.publish_result == "failed"]
private = [p for p in packages if p.is_private]
print(f"{c.BOLD}Summary:{c.NC}")
parts = [
f"{c.GREEN}{len(published)} published{c.NC}",
f"{c.CYAN}{len(exists)} already exist{c.NC}",
f"{c.RED}{len(failed)} failed{c.NC}",
f"{c.DIM}{len(private)} private{c.NC}",
]
print(" " + " · ".join(parts))
print()
def main() -> None:
import argparse
parser = argparse.ArgumentParser(
description="Publish packages to GitLab npm registry",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
gitlab-npm-publish.py # List all packages
gitlab-npm-publish.py --publish # Publish all packages
gitlab-npm-publish.py --publish --dry # Dry run (show what would be published)
gitlab-npm-publish.py --filter ui # Only packages matching 'ui'
gitlab-npm-publish.py --bump patch # Bump patch version before publish
""",
)
parser.add_argument("--publish", "-p", action="store_true", help="Actually publish packages")
parser.add_argument("--dry", "-d", action="store_true", help="Dry run (don't actually publish)")
parser.add_argument("--no-build", action="store_true", help="Skip build step")
parser.add_argument("--filter", "-f", type=str, help="Filter packages by name pattern")
parser.add_argument("--bump", "-b", choices=["patch", "minor", "major"], help="Bump version before publish")
parser.add_argument("--list", "-l", action="store_true", help="Just list packages (default)")
args = parser.parse_args()
c = Color
base_path = Path.cwd()
# Find packages
packages = find_packages(base_path)
if not packages:
print(f"{c.RED}No packages found in {base_path}{c.NC}")
sys.exit(1)
# Apply filter
if args.filter:
packages = [p for p in packages if args.filter.lower() in p.name.lower()]
if not packages:
print(f"{c.YELLOW}No packages matching '{args.filter}'{c.NC}")
sys.exit(0)
# List mode (default)
if not args.publish:
print_packages(packages, f"GitLab NPM Packages: {c.CYAN}{base_path.name}{c.NC}")
publishable = [p for p in packages if p.can_publish]
print(f"{c.BOLD}Publishable:{c.NC} {len(publishable)} of {len(packages)} packages")
print()
print(f"{c.DIM}Use --publish to publish, --dry for dry run{c.NC}")
return
# Publish mode
if not check_token():
sys.exit(1)
publishable = [p for p in packages if p.can_publish]
if not publishable:
print(f"{c.YELLOW}No publishable packages found{c.NC}")
sys.exit(0)
mode = "DRY RUN" if args.dry else "PUBLISH"
print(f"{c.BOLD}{'' * 80}{c.NC}")
print(f"{c.BOLD} GitLab NPM {mode}: {c.CYAN}{base_path.name}{c.NC}")
print(f"{c.BOLD}{'' * 80}{c.NC}")
print()
# Process each package
for pkg in publishable:
print(f"{c.BOLD}{pkg.name}@{pkg.version}{c.NC}")
# Bump version if requested
if args.bump:
if not bump_version(pkg, args.bump):
pkg.publish_result = "failed"
continue
print(f" {Color.GREEN}Bumped to {pkg.version}{Color.NC}")
# Build if needed
if not args.no_build:
if not build_package(pkg):
pkg.publish_result = "failed"
continue
# Publish
result = publish_package(pkg, dry_run=args.dry)
pkg.publish_result = result
if result == "success":
print(f" {c.GREEN}✓ Published {pkg.name}@{pkg.version}{c.NC}")
elif result == "exists":
print(f" {c.CYAN}= {pkg.version} already exists{c.NC}")
else:
print(f" {c.RED}✗ Failed{c.NC}")
print()
# Summary
print_summary(packages)
if __name__ == "__main__":
main()