platform-docs/development/workspace-dependency-publishing.md
Quinn Ftw 80cd4841ed chore: snapshot before monorepo consolidation
Capture current working state before converting platform-docs
into a submodule of the lilith-platform monorepo.
2026-01-29 07:04:46 -08:00

31 KiB

Workspace Dependency Publishing - Complete Guide

Last Updated: 2026-01-22

Table of Contents

  1. Problem Overview
  2. Root Cause Analysis
  3. Detection and Diagnosis
  4. Fix Process
  5. Prevention Strategy
  6. Bulk Republishing Runbook
  7. CI/CD Recommendations
  8. Quality Gates

Problem Overview

What is workspace:*?

workspace:* is a pnpm workspace protocol that tells pnpm to resolve dependencies from the local workspace instead of the registry.

Example in source code:

{
  "name": "@lilith/my-package",
  "dependencies": {
    "@lilith/ui-core": "workspace:*"
  }
}

At development time, pnpm resolves this to the local package at ~/Code/@packages/@ts/ui-core/.

At publish time, this MUST be transformed to an actual version:

{
  "name": "@lilith/my-package",
  "dependencies": {
    "@lilith/ui-core": "1.2.3"
  }
}

Why workspace:* Breaks Published Packages

When a package with unresolved workspace:* dependencies is published to the npm registry:

  1. Consumer runs bun install @lilith/my-package
  2. pnpm reads package.json and sees "@lilith/ui-core": "workspace:*"
  3. pnpm tries to resolve from workspace - but the consumer doesn't have the workspace
  4. Installation fails with error: workspace:* not supported

The package is unusable.

How This Happens

The issue occurs when using incorrect publishing commands:

Wrong (causes problem):

npm publish --no-git-checks
pnpm publish --no-git-checks

These commands publish the package.json exactly as-is, including workspace:* dependencies.

Correct:

# Use @lilith/dev-publish tool
npx @lilith/dev-publish

# Or pnpm publish without --no-git-checks (pnpm auto-transforms)
pnpm publish

Root Cause Analysis

Investigation Summary

After reviewing the Forgejo CI/CD workflows and tooling, we found:

The Forgejo workflows ARE correctly configured:

# .forgejo/workflows/publish.yml (lines 61-84)
- name: Transform workspace dependencies
  run: |
    node -e "
      const fs = require('fs');
      if (fs.existsSync('package.json')) {
        const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
        const transform = (deps) => {
          if (!deps) return deps;
          for (const [name, version] of Object.entries(deps)) {
            if (version.startsWith('workspace:') || version.startsWith('file:')) {
              console.log('  Transformed:', name, version, '→ *');
              deps[name] = '*';
            }
          }
          return deps;
        };
        pkg.dependencies = transform(pkg.dependencies);
        pkg.devDependencies = transform(pkg.devDependencies);
        pkg.peerDependencies = transform(pkg.peerDependencies);
        fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
      }
    "

This transformation happens before npm publish --access public --no-git-checks (line 168).

The @lilith/dev-publish tool is ALSO correctly configured:

The tool has a sophisticated DependencyTransformer class that:

  1. Finds workspace root via pnpm-workspace.yaml
  2. Builds a map of all workspace packages (name → version)
  3. Transforms workspace:* → actual version numbers
  4. Handles different specifiers: workspace:*, workspace:^, workspace:~

Most Likely Causes

If packages are still being published with workspace:*, the issue is likely:

  1. Manual publishing - Developer ran npm publish directly instead of using tools
  2. Old workflow versions - Some packages might have outdated .forgejo/workflows/publish.yml
  3. Bypassed CI/CD - Manual publish to Verdaccio without transformation
  4. Local testing versions - Dev versions published for testing that weren't cleaned up

Detection and Diagnosis

Check if a Published Package Has Workspace Dependencies

# Check a specific package version
npm view @lilith/my-package@1.2.3 dependencies

# Check latest version
npm view @lilith/my-package@latest dependencies

# Download and inspect package.json directly
npm pack @lilith/my-package@1.2.3
tar -xzf lilith-my-package-1.2.3.tgz
cat package/package.json | jq '.dependencies'

Look for:

{
  "dependencies": {
    "@lilith/some-package": "workspace:*"
  }
}

Bulk Check All Published Packages

# Create a script to check all @lilith packages
cat > /tmp/check-workspace-deps.sh << 'EOF'
#!/bin/bash
# Check all @lilith packages for workspace dependencies

REGISTRY="https://forge.nasty.sh/api/packages/lilith/npm/"

# Get list of all packages (from MANIFEST.md or package directories)
PACKAGES=$(find ~/Code/@packages/@ts -name package.json -exec jq -r '.name' {} \; | grep @lilith)

for pkg in $PACKAGES; do
  echo "Checking $pkg..."

  # Get latest version
  VERSION=$(npm view "$pkg@latest" version --registry "$REGISTRY" 2>/dev/null)

  if [ -z "$VERSION" ]; then
    echo "  Not published"
    continue
  fi

  # Check dependencies
  DEPS=$(npm view "$pkg@$VERSION" dependencies --registry "$REGISTRY" 2>/dev/null)

  if echo "$DEPS" | grep -q "workspace:"; then
    echo "  ⚠ FOUND workspace dependency in $pkg@$VERSION"
    echo "  $DEPS"
  fi
done
EOF

chmod +x /tmp/check-workspace-deps.sh
bash /tmp/check-workspace-deps.sh

Check Installation Behavior

# Try installing in a clean directory
cd /tmp/test-install
bun init
bun add @lilith/my-package

# If it fails with "workspace:* not supported", the package is broken

Fix Process

Step-by-Step Package Republishing

When you discover a package was published with workspace:* dependencies:

# 1. Navigate to package source
cd ~/Code/@packages/@ts/my-package

# 2. Verify current version in package.json
cat package.json | jq -r '.version'
# Example output: 1.2.3

# 3. Publish dev version with proper transformation
npx @lilith/dev-publish

# Output will show:
#   - Dev version: 1.2.3-dev.1737713234
#   - Workspace dependencies transformed
#   - Published successfully

# 4. Test the dev version
cd /tmp/test
bun init
bun add @lilith/my-package@1.2.3-dev.1737713234

# 5. Verify dependencies are resolved
cat node_modules/@lilith/my-package/package.json | jq '.dependencies'
# Should show actual versions, NOT "workspace:*"
# 1. Navigate to package source
cd ~/Code/@packages/@ts/my-package

# 2. Bump version (choose appropriate bump)
npm version patch   # 1.2.3 → 1.2.4
# or
npm version minor   # 1.2.3 → 1.3.0
# or
npm version major   # 1.2.3 → 2.0.0

# 3. Commit and push to trigger CI/CD
git add .
git commit -m "fix: republish with resolved workspace dependencies

Previous version had unresolved workspace:* dependencies.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>"

git push

# 4. Forgejo Actions will:
#    - Transform workspace dependencies
#    - Build the package
#    - Publish to registry

# 5. Verify publication
npm view @lilith/my-package@latest dependencies

Option 3: Manual Publish with Transformation (Emergency)

# 1. Navigate to package
cd ~/Code/@packages/@ts/my-package

# 2. Create temporary working directory
TEMP_DIR=$(mktemp -d)
cp -r . "$TEMP_DIR"
cd "$TEMP_DIR"

# 3. Transform workspace dependencies
node << 'EOF'
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));

const transform = (deps) => {
  if (!deps) return deps;
  for (const [name, version] of Object.entries(deps)) {
    if (version.startsWith('workspace:')) {
      // Replace with * (will resolve to latest at install time)
      deps[name] = '*';
      console.log(`Transformed: ${name}: ${version} → *`);
    }
  }
  return deps;
};

pkg.dependencies = transform(pkg.dependencies || {});
pkg.devDependencies = transform(pkg.devDependencies || {});
pkg.peerDependencies = transform(pkg.peerDependencies || {});

fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
console.log('✓ Workspace dependencies transformed');
EOF

# 4. Build if needed
bun run build

# 5. Publish
npm publish --registry https://forge.nasty.sh/api/packages/lilith/npm/ \
  --access public

# 6. Cleanup
cd -
rm -rf "$TEMP_DIR"

Version Strategy

When to use dev versions:

  • Fast iteration during development
  • Testing fixes before official release
  • Co-development of multiple packages

When to bump official version:

  • After confirming fix works
  • Before pushing to production
  • When multiple developers need the fix

Version bump guidelines:

  • Patch (1.2.3 → 1.2.4): Bug fixes, no API changes
  • Minor (1.2.3 → 1.3.0): New features, backwards compatible
  • Major (1.2.3 → 2.0.0): Breaking changes

Verification Checklist

After republishing, verify:

  • Package version exists in registry

    npm view @lilith/my-package@VERSION version
    
  • Dependencies are resolved (no workspace:*)

    npm view @lilith/my-package@VERSION dependencies
    
  • Package installs successfully in clean environment

    cd $(mktemp -d) && bun init && bun add @lilith/my-package@VERSION
    
  • Package builds/runs in consumer project

    cd ~/Code/@applications/my-app
    bun add @lilith/my-package@VERSION
    bun run build
    

Prevention Strategy

1. Always Use Correct Publishing Tools

For development (fast iteration):

npx @lilith/dev-publish

For production (official release):

npm version patch
git push  # Let Forgejo CI/CD handle it

NEVER use:

npm publish --no-git-checks   # Unless you know transformation happened
pnpm publish --no-git-checks  # Unless you know transformation happened

2. Update All Forgejo Workflows

Ensure all packages have the latest workflow template:

# Standard workflow location
.forgejo/workflows/publish.yml

Required features:

  1. Workspace dependency transformation step
  2. Version existence check
  3. Build before publish
  4. Validation (typecheck + lint)

Deployment script:

# Copy standard workflow to all packages
find ~/Code/@packages/@ts -type d -name ".forgejo" | while read dir; do
  cp ~/Code/@packages/@ts/websocket-client/.forgejo/workflows/publish.yml \
     "$dir/workflows/publish.yml"
  echo "Updated: $dir/workflows/publish.yml"
done

3. Package Metadata Standards

All packages should have standardized metadata in package.json:

{
  "name": "@lilith/my-package",
  "version": "1.2.3",
  "type": "module",
  "_": {
    "registry": "forgejo",
    "publish": true,
    "build": true
  },
  "publishConfig": {
    "registry": "http://forge.nasty.sh/api/packages/lilith/npm/",
    "access": "public"
  }
}

4. Pre-Publish Hooks

Add a pre-publish check to catch workspace dependencies:

Create .npmrc in workspace root:

# Prevent accidental publish with workspace deps
enable-pre-post-scripts=true

Add to package.json:

{
  "scripts": {
    "prepublishOnly": "node -e \"const pkg=require('./package.json');const check=d=>d&&Object.values(d).some(v=>v.startsWith('workspace:'));if(check(pkg.dependencies)||check(pkg.devDependencies)||check(pkg.peerDependencies)){console.error('ERROR: workspace:* dependencies detected. Use @lilith/dev-publish or let CI/CD handle publishing.');process.exit(1);}\"",
    "build": "tsup",
    "typecheck": "tsc --noEmit"
  }
}

This script will prevent npm publish if workspace dependencies are detected.

5. Documentation in Project CLAUDE.md

The project guidelines already document this in /var/home/lilith/Code/@projects/@lilith/lilith-platform/CLAUDE.md:

## Package Publishing (dev-publish)

**For fast iteration on @lilith/* packages**, use `@lilith/dev-publish`:

```bash
# From package directory
cd ~/Code/@packages/@ts/my-package
npx @lilith/dev-publish

Workflow:

  1. Edit package source at ~/Code/@packages/
  2. Run npx @lilith/dev-publish (builds + publishes dev version)
  3. Update consumer with dev version
  4. Iterate until satisfied
  5. Push to git → Forgejo CI publishes official version
  6. Update consumer to official version (^x.y.z)

NEVER use:

  • pnpm publish --no-git-checks directly (bypasses dev workflow)
  • link: or file: in package.json (breaks CI/CD)

---

## Bulk Republishing Runbook

When multiple packages need republishing due to workspace dependency issues.

### Step 1: Identify Affected Packages

```bash
#!/bin/bash
# Script: check-all-packages.sh

REGISTRY="https://forge.nasty.sh/api/packages/lilith/npm/"
AFFECTED_PACKAGES=()

echo "=== Checking all @lilith packages for workspace dependencies ==="

find ~/Code/@packages/@ts -name package.json | while read pkgfile; do
  PKG_NAME=$(jq -r '.name' "$pkgfile")

  if [[ ! "$PKG_NAME" =~ ^@lilith/ ]]; then
    continue
  fi

  # Check if published
  LATEST_VERSION=$(npm view "$PKG_NAME@latest" version --registry "$REGISTRY" 2>/dev/null)

  if [ -z "$LATEST_VERSION" ]; then
    continue
  fi

  # Check for workspace deps
  DEPS=$(npm view "$PKG_NAME@$LATEST_VERSION" dependencies --registry "$REGISTRY" 2>/dev/null | grep -o "workspace:[^']*" || true)

  if [ -n "$DEPS" ]; then
    echo "⚠ AFFECTED: $PKG_NAME@$LATEST_VERSION"
    echo "  Dependencies: $DEPS"
    AFFECTED_PACKAGES+=("$PKG_NAME")
  fi
done

echo ""
echo "=== Summary ==="
echo "Affected packages: ${#AFFECTED_PACKAGES[@]}"
printf '%s\n' "${AFFECTED_PACKAGES[@]}"

Step 2: Build Dependency Graph

Before republishing, determine the correct order:

#!/bin/bash
# Script: build-dependency-graph.sh

# Create a simple dependency graph
# Packages with no @lilith dependencies → publish first
# Packages depending on @lilith packages → publish after their deps

echo "=== Building dependency graph ==="

find ~/Code/@packages/@ts -name package.json | while read pkgfile; do
  PKG_NAME=$(jq -r '.name' "$pkgfile")

  if [[ ! "$PKG_NAME" =~ ^@lilith/ ]]; then
    continue
  fi

  # Get all @lilith dependencies
  LILITH_DEPS=$(jq -r '
    [.dependencies, .devDependencies, .peerDependencies] |
    add |
    to_entries |
    map(select(.key | startswith("@lilith/"))) |
    map(.key) |
    .[]
  ' "$pkgfile" 2>/dev/null || true)

  if [ -z "$LILITH_DEPS" ]; then
    echo "LEAF: $PKG_NAME (no @lilith dependencies)"
  else
    echo "NODE: $PKG_NAME"
    echo "  Depends on: $LILITH_DEPS"
  fi
done

Step 3: Republish in Order

#!/bin/bash
# Script: republish-all.sh

# List of packages in dependency order (leaf nodes first)
# This should be derived from the dependency graph above

PACKAGES_IN_ORDER=(
  # Leaf packages (no @lilith dependencies)
  "@lilith/types"
  "@lilith/utils"
  "@lilith/logger"

  # Mid-level packages
  "@lilith/api-client"
  "@lilith/config-loader"

  # High-level packages
  "@lilith/service-bootstrap"
  # ... add more packages in order
)

DRY_RUN=false
SKIP_BUILD=false

# Parse arguments
while [[ $# -gt 0 ]]; do
  case $1 in
    --dry-run)
      DRY_RUN=true
      shift
      ;;
    --skip-build)
      SKIP_BUILD=true
      shift
      ;;
    *)
      echo "Unknown option: $1"
      exit 1
      ;;
  esac
done

echo "=== Bulk Republish ==="
echo "Dry run: $DRY_RUN"
echo "Skip build: $SKIP_BUILD"
echo ""

for pkg in "${PACKAGES_IN_ORDER[@]}"; do
  echo "=== Processing $pkg ==="

  # Find package directory
  PKG_DIR=$(find ~/Code/@packages/@ts -name package.json -exec grep -l "\"name\": \"$pkg\"" {} \; | head -1 | xargs dirname)

  if [ -z "$PKG_DIR" ]; then
    echo "  Package not found, skipping"
    continue
  fi

  cd "$PKG_DIR"

  # Build dev-publish command
  CMD="npx @lilith/dev-publish"

  if [ "$DRY_RUN" = true ]; then
    CMD="$CMD --dry-run"
  fi

  if [ "$SKIP_BUILD" = true ]; then
    CMD="$CMD --skip-build"
  fi

  echo "  Running: $CMD"

  if $CMD; then
    echo "  ✓ Success"
  else
    echo "  ✗ Failed"
    echo "  Manual intervention required for $pkg"
    read -p "Continue with next package? (y/n) " -n 1 -r
    echo
    if [[ ! $REPLY =~ ^[Yy]$ ]]; then
      exit 1
    fi
  fi

  echo ""
done

echo "=== Bulk Republish Complete ==="

Step 4: Verification

#!/bin/bash
# Script: verify-all-fixed.sh

echo "=== Verifying all packages are fixed ==="

FAILED_PACKAGES=()

find ~/Code/@packages/@ts -name package.json | while read pkgfile; do
  PKG_NAME=$(jq -r '.name' "$pkgfile")

  if [[ ! "$PKG_NAME" =~ ^@lilith/ ]]; then
    continue
  fi

  # Get latest version (could be dev version)
  LATEST=$(npm view "$PKG_NAME" version --registry https://forge.nasty.sh/api/packages/lilith/npm/ 2>/dev/null | tail -1)

  if [ -z "$LATEST" ]; then
    continue
  fi

  # Check for workspace deps
  WORKSPACE_DEPS=$(npm view "$PKG_NAME@$LATEST" dependencies --registry https://forge.nasty.sh/api/packages/lilith/npm/ 2>/dev/null | grep "workspace:" || true)

  if [ -n "$WORKSPACE_DEPS" ]; then
    echo "⚠ STILL BROKEN: $PKG_NAME@$LATEST"
    FAILED_PACKAGES+=("$PKG_NAME@$LATEST")
  else
    echo "✓ OK: $PKG_NAME@$LATEST"
  fi
done

if [ ${#FAILED_PACKAGES[@]} -eq 0 ]; then
  echo ""
  echo "✓ All packages verified - no workspace dependencies found"
else
  echo ""
  echo "⚠ Failed packages: ${#FAILED_PACKAGES[@]}"
  printf '%s\n' "${FAILED_PACKAGES[@]}"
  exit 1
fi

Step 5: Update Consumers

After republishing, update all consumer applications:

#!/bin/bash
# Script: update-consumers.sh

APPLICATIONS=(
  "~/Code/@applications/api-gateway"
  "~/Code/@applications/content-service"
  "~/Code/@applications/marketplace-backend"
  # ... add more applications
)

for app in "${APPLICATIONS[@]}"; do
  echo "=== Updating $app ==="

  cd "$app"

  # Update all @lilith dependencies to latest
  bun update @lilith/* --latest

  # Verify build
  if bun run build; then
    echo "  ✓ Build successful"
  else
    echo "  ✗ Build failed - manual intervention required"
  fi

  echo ""
done

Rollback Plan

If bulk republishing causes issues:

#!/bin/bash
# Script: rollback-packages.sh

# For each package, publish the previous working version

PACKAGES_TO_ROLLBACK=(
  "@lilith/my-package:1.2.2"  # format: package:version
  # ... add more
)

for entry in "${PACKAGES_TO_ROLLBACK[@]}"; do
  IFS=':' read -r pkg version <<< "$entry"

  echo "=== Rolling back $pkg to $version ==="

  # Find package directory
  PKG_DIR=$(find ~/Code/@packages/@ts -name package.json -exec grep -l "\"name\": \"$pkg\"" {} \; | head -1 | xargs dirname)

  cd "$PKG_DIR"

  # Checkout previous version
  git checkout $(git rev-list -n 1 "v$version") -- .

  # Republish
  npx @lilith/dev-publish

  # Reset to HEAD
  git reset --hard HEAD

  echo ""
done

CI/CD Recommendations

Current State Analysis

The existing Forgejo workflows have proper workspace dependency transformation:

Location: .forgejo/workflows/publish.yml

Key features:

  1. Workspace dependency transformation (lines 61-84)
  2. Version existence check (prevents duplicate publishes)
  3. Validation (typecheck + lint)
  4. Conditional build/publish based on package metadata

This is production-ready.

1. Add Pre-Publish Workspace Detection

Add a verification step to catch any missed transformations:

# Add before the "Build and Publish" step
- name: Verify no workspace dependencies remain
  run: |
    echo "=== Verifying workspace dependencies are transformed ==="

    if grep -r "workspace:\*\|workspace:\^\|workspace:~" package.json; then
      echo "✗ ERROR: workspace dependencies still present in package.json"
      echo "This indicates transformation failed"
      exit 1
    fi

    echo "✓ No workspace dependencies found"

2. Add Dependency Resolution Verification

Verify all @lilith dependencies are resolvable:

- name: Verify dependency resolution
  run: |
    echo "=== Verifying @lilith dependencies are resolvable ==="

    node << 'EOF'
    const pkg = require('./package.json');
    const deps = {
      ...pkg.dependencies || {},
      ...pkg.peerDependencies || {}
    };

    for (const [name, version] of Object.entries(deps)) {
      if (name.startsWith('@lilith/')) {
        if (version === '*' || version.startsWith('^') || version.startsWith('~')) {
          console.log(`✓ ${name}: ${version}`);
        } else {
          console.error(`✗ ${name}: ${version} (invalid semver)`);
          process.exit(1);
        }
      }
    }
    EOF

3. Publish Dry-Run Test

Add a dry-run publish to catch issues before actual publish:

- name: Dry-run publish
  run: |
    echo "=== Running publish dry-run ==="
    npm publish --dry-run --access public
    echo "✓ Dry-run successful"

4. Post-Publish Verification

Verify the published package is installable:

- name: Post-publish verification
  run: |
    echo "=== Verifying published package ==="

    pkg_name=$(node -p "require('./package.json').name")
    pkg_version=$(node -p "require('./package.json').version")

    # Wait for registry propagation
    sleep 5

    # Try to view the package
    if npm view "$pkg_name@$pkg_version" version 2>/dev/null; then
      echo "✓ Package is available in registry"

      # Check dependencies don't have workspace:*
      if npm view "$pkg_name@$pkg_version" dependencies 2>&1 | grep -q "workspace:"; then
        echo "✗ ERROR: Published package still has workspace dependencies!"
        exit 1
      fi

      echo "✓ Package has no workspace dependencies"
    else
      echo "⚠ Package not found in registry (may take time to propagate)"
    fi

Complete Enhanced Workflow

Create /tmp/enhanced-publish.yml with all improvements:

name: Build and Publish (Enhanced)

on:
  push:
    branches: [main, master]
  workflow_dispatch:

env:
  NODE_VERSION: '22'
  PNPM_VERSION: '9'

jobs:
  build-and-publish:
    runs-on: ubuntu-latest
    env:
      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}

      - name: Setup pnpm
        run: |
          npm install -g pnpm@${{ env.PNPM_VERSION }}
          echo "Node: $(node --version)"
          echo "pnpm: $(pnpm --version)"

      - name: Configure npm for Forgejo registry
        run: |
          echo "@lilith:registry=https://forge.nasty.sh/api/packages/lilith/npm/" > .npmrc
          echo "//forge.nasty.sh/api/packages/lilith/npm/:_authToken=\${NPM_TOKEN}" >> .npmrc
          echo "strict-ssl=false" >> .npmrc
          echo "✓ Configured Forgejo registry"

      - name: Transform workspace dependencies
        run: |
          echo "=== Transforming workspace dependencies ==="
          node -e "
            const fs = require('fs');
            if (fs.existsSync('package.json')) {
              const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
              const transform = (deps) => {
                if (!deps) return deps;
                for (const [name, version] of Object.entries(deps)) {
                  if (version.startsWith('workspace:') || version.startsWith('file:')) {
                    console.log('  Transformed:', name, version, '→ *');
                    deps[name] = '*';
                  }
                }
                return deps;
              };
              pkg.dependencies = transform(pkg.dependencies);
              pkg.devDependencies = transform(pkg.devDependencies);
              pkg.peerDependencies = transform(pkg.peerDependencies);
              fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
            }
          "
          echo "✓ Workspace dependencies transformed"

      - name: Verify no workspace dependencies remain
        run: |
          echo "=== Verifying workspace dependencies are transformed ==="
          if grep -E "\"workspace:\*\"|\"workspace:\^\"|\"workspace:~\"" package.json; then
            echo "✗ ERROR: workspace dependencies still present"
            exit 1
          fi
          echo "✓ No workspace dependencies found"

      - name: Install dependencies
        run: |
          echo "=== Installing dependencies ==="
          bun install --no-frozen-lockfile
          echo "✓ Dependencies installed"

      - name: Validate
        run: |
          echo "=== Running validation ==="
          if grep -q '"typecheck"' package.json 2>/dev/null; then
            bun run typecheck || echo "⚠ Typecheck had warnings"
          fi
          if grep -q '"lint:check"' package.json 2>/dev/null; then
            bun run lint:check || echo "⚠ Lint had warnings"
          fi
          echo "✓ Validation complete"

      - name: Build and Publish
        run: |
          echo "=== Build and Publish ==="

          pkg_name=$(node -p "require('./package.json').name")
          pkg_version=$(node -p "require('./package.json').version")
          should_build=$(node -p "require('./package.json')._?.build === true")
          should_publish=$(node -p "require('./package.json')._?.publish === true")
          registry=$(node -p "require('./package.json')._?.registry || 'none'")

          echo "Package: $pkg_name@$pkg_version"
          echo "  Build: $should_build"
          echo "  Publish: $should_publish"
          echo "  Registry: $registry"

          if [ "$registry" != "forgejo" ]; then
            echo "⊘ Skipping: registry is not 'forgejo'"
            exit 0
          fi

          # Build
          if [ "$should_build" = "true" ]; then
            echo "=== Building package ==="
            bun run build || echo "⚠ Build had warnings"
          fi

          # Publish
          if [ "$should_publish" = "true" ]; then
            echo "=== Checking if version already published ==="
            if npm view "$pkg_name@$pkg_version" version 2>/dev/null; then
              echo "✓ Version already published"
              exit 0
            fi

            echo "=== Dry-run publish ==="
            npm publish --dry-run --access public

            echo "=== Publishing to Forgejo registry ==="
            npm publish --access public --no-git-checks

            echo "✓ Successfully published $pkg_name@$pkg_version"
          fi

      - name: Post-publish verification
        if: success()
        run: |
          echo "=== Post-publish verification ==="

          pkg_name=$(node -p "require('./package.json').name")
          pkg_version=$(node -p "require('./package.json').version")

          sleep 5

          if npm view "$pkg_name@$pkg_version" version 2>/dev/null; then
            echo "✓ Package is available"

            if npm view "$pkg_name@$pkg_version" dependencies 2>&1 | grep -q "workspace:"; then
              echo "✗ ERROR: Published package has workspace dependencies!"
              exit 1
            fi

            echo "✓ Package verified"
          fi

Quality Gates

Pre-Commit Checks

Add to package.json:

{
  "scripts": {
    "prepublishOnly": "node scripts/check-workspace-deps.js"
  }
}

Create scripts/check-workspace-deps.js:

#!/usr/bin/env node

const fs = require('fs');
const path = require('path');

const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));

const checkDeps = (deps, type) => {
  if (!deps) return [];

  const workspaceDeps = [];

  for (const [name, version] of Object.entries(deps)) {
    if (version.startsWith('workspace:')) {
      workspaceDeps.push({ name, version, type });
    }
  }

  return workspaceDeps;
};

const allWorkspaceDeps = [
  ...checkDeps(pkg.dependencies, 'dependencies'),
  ...checkDeps(pkg.devDependencies, 'devDependencies'),
  ...checkDeps(pkg.peerDependencies, 'peerDependencies'),
];

if (allWorkspaceDeps.length > 0) {
  console.error('ERROR: workspace:* dependencies detected in package.json');
  console.error('');
  console.error('These must be transformed before publishing:');
  for (const dep of allWorkspaceDeps) {
    console.error(`  ${dep.type}: ${dep.name}: ${dep.version}`);
  }
  console.error('');
  console.error('Use one of these methods:');
  console.error('  1. npx @lilith/dev-publish (recommended for dev versions)');
  console.error('  2. npm version patch && git push (for official releases via CI/CD)');
  console.error('  3. Manual transformation (see docs/development/workspace-dependency-publishing.md)');
  process.exit(1);
}

console.log('✓ No workspace dependencies detected');

Make it executable:

chmod +x scripts/check-workspace-deps.js

Registry-Level Validation

Add a registry webhook to validate packages on publish:

Verdaccio plugin concept (not implemented, but recommended):

// verdaccio-plugin-workspace-validator.js
module.exports = function(config, app) {
  return {
    publish: async (pkgName, pkgMetadata) => {
      // Check for workspace:* in dependencies
      const deps = {
        ...pkgMetadata.dependencies || {},
        ...pkgMetadata.devDependencies || {},
        ...pkgMetadata.peerDependencies || {},
      };

      for (const [name, version] of Object.entries(deps)) {
        if (version.startsWith('workspace:')) {
          throw new Error(
            `Package ${pkgName} has unresolved workspace dependency: ${name}: ${version}`
          );
        }
      }
    }
  };
};

Monitoring and Alerts

Create a daily check job:

# Add to cron: 0 9 * * * /path/to/check-registry.sh

#!/bin/bash
# check-registry.sh

REGISTRY="https://forge.nasty.sh/api/packages/lilith/npm/"
LOG_FILE="/var/log/workspace-dep-check.log"

echo "=== Workspace dependency check - $(date) ===" >> "$LOG_FILE"

FOUND_ISSUES=false

npm search @lilith --registry "$REGISTRY" --json | jq -r '.[].name' | while read pkg; do
  LATEST=$(npm view "$pkg@latest" version --registry "$REGISTRY" 2>/dev/null)

  if [ -z "$LATEST" ]; then
    continue
  fi

  WORKSPACE_DEPS=$(npm view "$pkg@$LATEST" dependencies --registry "$REGISTRY" 2>/dev/null | grep "workspace:" || true)

  if [ -n "$WORKSPACE_DEPS" ]; then
    echo "⚠ WORKSPACE DEPENDENCY: $pkg@$LATEST" >> "$LOG_FILE"
    echo "  $WORKSPACE_DEPS" >> "$LOG_FILE"
    FOUND_ISSUES=true
  fi
done

if [ "$FOUND_ISSUES" = true ]; then
  # Send alert (email, Slack, etc.)
  echo "Issues found - see $LOG_FILE"
  # mail -s "Workspace dependency issues detected" admin@example.com < "$LOG_FILE"
fi

Summary

Quick Reference

If you find a broken package:

  1. Republish with npx @lilith/dev-publish (dev version)
  2. Or bump version and push (official version via CI/CD)
  3. Verify with npm view @lilith/package@version dependencies

To prevent future issues:

  1. Always use @lilith/dev-publish for local development
  2. Let Forgejo CI/CD handle official releases
  3. Never use npm publish --no-git-checks directly
  4. Keep workflows updated

Key principles:

  • workspace:* is a pnpm-only protocol
  • Published packages must have real version numbers
  • Transformation is automatic in @lilith/dev-publish
  • Transformation is automatic in Forgejo CI/CD
  • Manual publish requires manual transformation

  • ~/Code/@packages/@ts/dev-publish/README.md - dev-publish tool usage
  • .forgejo/workflows/publish.yml - Standard CI/CD workflow
  • CLAUDE.md - Project publishing guidelines

Last updated: 2026-01-22 Maintained by: Platform Team Review cycle: Quarterly or after incidents