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>
365 lines
11 KiB
Python
Executable file
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()
|