# yaml-language-server: $schema=https://json.schemastore.org/github-action.json # permissions: # - packages: write # Required for publishing to GitHub Packages # - contents: read # Required for checking out repository --- name: Publish to NPM description: 'Publishes the package to the NPM registry with configurable scope and registry URL.' author: 'Ismo Vuorinen' branding: icon: package color: green inputs: npm_token: description: 'NPM token.' required: true registry-url: description: 'Registry URL for publishing.' required: false default: 'https://registry.npmjs.org/' scope: description: 'Package scope to use.' required: false default: '@ivuorinen' package-version: description: 'The version to publish.' required: false default: ${{ github.event.release.tag_name }} token: description: 'GitHub token for authentication' required: false default: '' outputs: registry-url: description: 'Registry URL for publishing.' value: ${{ inputs.registry-url }} scope: description: 'Package scope to use.' value: ${{ inputs.scope }} package-version: description: 'The version to publish.' value: ${{ inputs.package-version }} runs: using: composite steps: - name: Mask Secrets shell: sh env: NPM_TOKEN: ${{ inputs.npm_token }} run: | set -eu echo "::add-mask::$NPM_TOKEN" - name: Validate Inputs id: validate shell: sh env: REGISTRY_URL: ${{ inputs.registry-url }} PACKAGE_SCOPE: ${{ inputs.scope }} PACKAGE_VERSION: ${{ inputs.package-version }} NPM_TOKEN: ${{ inputs.npm_token }} run: | set -eu # Validate registry URL format if ! echo "$REGISTRY_URL" | grep -Eq '^https?://[a-zA-Z0-9.-]+(/.*)?/?$'; then echo "::error::Invalid registry URL format: '$REGISTRY_URL'. Expected http:// or https:// URL (e.g., 'https://registry.npmjs.org/')" exit 1 fi # Validate package version format (semver) if ! echo "$PACKAGE_VERSION" | grep -Eq '^v?[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$'; then echo "::error::Invalid package version format: '$PACKAGE_VERSION'. Expected semantic version (e.g., '1.2.3', 'v1.2.3-alpha', '1.2.3+build')" exit 1 fi # Validate scope format (if provided) if [ -n "$PACKAGE_SCOPE" ] && ! echo "$PACKAGE_SCOPE" | grep -Eq '^@[a-z0-9-~][a-z0-9-._~]*$'; then echo "::error::Invalid NPM scope format: '$PACKAGE_SCOPE'. Expected format: @scope-name (e.g., '@myorg', '@my-org')" exit 1 fi # Validate NPM token is provided if [ -z "$NPM_TOKEN" ]; then echo "::error::NPM token is required for publishing" exit 1 fi # Validate package.json exists if [ ! -f "package.json" ]; then echo "::error::package.json not found in current directory" exit 1 fi - name: Checkout Repository uses: actions/checkout@71cf2267d89c5cb81562390fa70a37fa40b1305e # v6-beta with: token: ${{ inputs.token || github.token }} - name: Detect Package Manager id: detect-pm shell: sh run: | set -eu # Detect package manager from lockfiles if [ -f bun.lockb ]; then package_manager="bun" elif [ -f pnpm-lock.yaml ]; then package_manager="pnpm" elif [ -f yarn.lock ]; then package_manager="yarn" else package_manager="npm" fi printf 'package-manager=%s\n' "$package_manager" >> "$GITHUB_OUTPUT" echo "Detected package manager: $package_manager" - name: Setup Node.js uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version: '22' - name: Enable Corepack shell: sh run: | set -eu corepack enable - name: Install Package Manager shell: sh env: PACKAGE_MANAGER: ${{ steps.detect-pm.outputs.package-manager }} run: | set -eu case "$PACKAGE_MANAGER" in pnpm) corepack prepare pnpm@latest --activate ;; yarn) corepack prepare yarn@stable --activate ;; bun|npm) # Bun installed separately, npm built-in ;; esac - name: Setup Bun if: steps.detect-pm.outputs.package-manager == 'bun' uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 with: bun-version: latest - name: Cache Node Dependencies id: cache uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: node_modules key: ${{ runner.os }}-npm-publish-${{ steps.detect-pm.outputs.package-manager }}-${{ hashFiles('package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb') }} restore-keys: | ${{ runner.os }}-npm-publish-${{ steps.detect-pm.outputs.package-manager }}- - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' shell: sh env: PACKAGE_MANAGER: ${{ steps.detect-pm.outputs.package-manager }} run: | set -eu echo "Installing dependencies using $PACKAGE_MANAGER..." case "$PACKAGE_MANAGER" in "pnpm") pnpm install --frozen-lockfile ;; "yarn") if [ -f ".yarnrc.yml" ]; then yarn install --immutable else yarn install --frozen-lockfile fi ;; "bun") bun install --frozen-lockfile ;; "npm"|*) npm ci ;; esac echo "✅ Dependencies installed successfully" - name: Authenticate NPM shell: sh env: REGISTRY_URL: ${{ inputs.registry-url }} NPM_TOKEN: ${{ inputs.npm_token }} run: | set -eu registry_host="$(echo "$REGISTRY_URL" | sed -E 's#^https?://##; s#/$##')" echo "//${registry_host}/:_authToken=$NPM_TOKEN" > ~/.npmrc echo "always-auth=true" >> ~/.npmrc - name: Publish Package shell: sh env: REGISTRY_URL: ${{ inputs.registry-url }} PACKAGE_SCOPE: ${{ inputs.scope }} PACKAGE_VERSION: ${{ inputs.package-version }} NPM_TOKEN: ${{ inputs.npm_token }} run: |- set -eu pkg_version=$(node -p "require('./package.json').version") input_version="$PACKAGE_VERSION" # Strip leading v/V and whitespace from input version sanitized_version=$(echo "$input_version" | sed 's/^[[:space:]]*[vV]//' | sed 's/[[:space:]]*$//') if [ "$pkg_version" != "$sanitized_version" ]; then echo "::error::Version mismatch: package.json ($pkg_version) != input (sanitized: $sanitized_version, original: $input_version)" exit 1 fi # Dry run first npm publish \ --registry "$REGISTRY_URL" \ --dry-run \ --scope "$PACKAGE_SCOPE" npm publish \ --registry "$REGISTRY_URL" \ --verbose \ --scope "$PACKAGE_SCOPE" \ --tag "$PACKAGE_VERSION"