/** * Publish NPM Package Pipeline * * A resilient pipeline for building and publishing npm packages to Nexus. * * TODO: Future enhancement - Add support for running builds in Docker containers * with configurable Node.js and pnpm versions via pipeline parameters: * - nodeVersion: '20', '22', '24', etc. (default: use agent's Node version) * - pnpmVersion: '8', '9', '10', etc. (default: use agent's pnpm version) * - useContainer: true/false to opt-in to containerized builds * This would allow projects to specify exact Node/pnpm versions for reproducible builds. * * Usage in Jenkinsfile: * ```groovy * @Library(["spicy-automation@main"]) _ * * publishNpmPackage() * ``` * * With scoped package: * ```groovy * @Library(["spicy-automation@main"]) _ * * publishNpmPackage( * packageName: '@kodeniks/my-package', * ) * ``` * * With all options: * ```groovy * @Library(["spicy-automation@main"]) _ * * publishNpmPackage( * packageName: '@kodeniks/my-package', // Scoped packages supported * registry: 'nexus.kodeniks.com', * repository: 'npm-hosted', * credentialsId: 'kodeniks-nexus-repository', * gitCredentialsId: 'kodeniks-gitea-token', * agentLabel: 'docker', // Agent must have Node.js installed * ) * ``` */ def call(Map args = [:]) { def config = [ packageName: args.packageName ?: args.name, registry: args.registry ?: 'nexus.kodeniks.com', repository: args.repository ?: 'npm-hosted', credentialsId: args.credentialsId ?: 'kodeniks-nexus-repository', gitCredentialsId: args.gitCredentialsId ?: 'kodeniks-gitea-token', agentLabel: args.agentLabel ?: 'docker', gitPushRetries: args.gitPushRetries ?: 3, ] // Store original git remote URL for cleanup def originalGitRemote = null pipeline { agent { label config.agentLabel } stages { stage('Checkout') { steps { checkout scm script { // Validate package name if (!config.packageName) { // Try to read from package.json try { npmUtils.validatePackageJson() config.packageName = npmUtils.npmPackageName() echo "Auto-detected package name: ${config.packageName}" } catch (Exception e) { error("Package name not found. Please provide packageName in the arguments or ensure package.json exists with a 'name' field.") } } echo "==========================================" echo "Package: ${config.packageName}" echo "==========================================" // Store original git remote for cleanup originalGitRemote = sh( script: 'git config --get remote.origin.url 2>/dev/null || echo ""', returnStdout: true ).trim() env.GIT_ORIGINAL_REMOTE_URL = originalGitRemote giteaUtils.setSuccess("checkout") } } } stage('Get Version') { steps { script { echo "==========================================" echo "Stage: Get Version" echo "==========================================" // Check if this build was triggered by a tag push // This prevents infinite loops where tag push → new build → version bump → tag push def isTagBuild = false def currentTag = null // Check if HEAD is already a tag try { currentTag = sh( script: 'git describe --exact-match --tags HEAD 2>/dev/null || echo ""', returnStdout: true ).trim() if (currentTag && !currentTag.isEmpty()) { isTagBuild = true echo "Detected tag build: HEAD is tagged as ${currentTag}" } } catch (Exception e) { // Not a tag, continue } // Also check if BRANCH_NAME indicates a tag (Jenkins sets this for tag builds) if (!isTagBuild && env.BRANCH_NAME && env.BRANCH_NAME.startsWith('tags/')) { isTagBuild = true echo "Detected tag build: BRANCH_NAME is ${env.BRANCH_NAME}" } // Check if commit message indicates this is a version bump commit if (!isTagBuild) { try { def commitMsg = sh( script: 'git log -1 --pretty=%B', returnStdout: true ).trim() if (commitMsg.contains('chore: bump version') || commitMsg.contains('bump version')) { isTagBuild = true echo "Detected tag build: commit message indicates version bump: ${commitMsg}" } } catch (Exception e) { // Continue if we can't check commit message } } if (isTagBuild) { echo "==========================================" echo "⚠️ Skipping publish: Build triggered by tag push" echo "This prevents infinite version bump loops" echo "==========================================" env.SKIP_PUBLISH = 'true' // Still set a version for consistency, but we won't publish def currentVersion = npmUtils.npmPackageVersion() env.PACKAGE_VERSION = currentVersion echo "Current package version: ${currentVersion}" giteaUtils.setSuccess("version") return } // Validate package.json before version check npmUtils.validatePackageJson() // Get version using npmUtils - checks Nexus and auto-bumps if needed def versionOutput = npmUtils.ensureVersion([ packageName: config.packageName, registry: config.registry, repository: config.repository, credentialsId: config.credentialsId ]) env.PACKAGE_VERSION = versionOutput echo "✓ Version determined: ${versionOutput}" giteaUtils.setSuccess("version") } } } stage('Install Dependencies') { when { expression { env.SKIP_PUBLISH != 'true' } } steps { script { echo "==========================================" echo "Stage: Install Dependencies" echo "==========================================" def lockFile = null def installCommand = null if(fileExists("package-lock.json")) { lockFile = "package-lock.json" installCommand = "npm install" } else if(fileExists("pnpm-lock.yaml")) { lockFile = "pnpm-lock.yaml" installCommand = "pnpm install" } else if(fileExists("yarn.lock")) { lockFile = "yarn.lock" installCommand = "yarn install" } else { error("No lock file found. Please ensure you have a package-lock.json, pnpm-lock.yaml, or yarn.lock file in your repository.") } echo "Using lock file: ${lockFile}" echo "Running: ${installCommand}" sh(installCommand) echo "✓ Dependencies installed" } } } stage('Build Package') { when { expression { env.SKIP_PUBLISH != 'true' } } steps { script { echo "==========================================" echo "Stage: Build Package" echo "==========================================" def buildCommand = null if(fileExists("package-lock.json")) { buildCommand = "npm run build" } else if(fileExists("pnpm-lock.yaml")) { buildCommand = "pnpm run build" } else if(fileExists("yarn.lock")) { buildCommand = "yarn run build" } else { error("No lock file found. Please ensure you have a package-lock.json, pnpm-lock.yaml, or yarn.lock file in your repository.") } echo "Running: ${buildCommand}" sh(buildCommand) echo "✓ Build completed" giteaUtils.setSuccess("build") } } } stage('Publish Package') { when { expression { env.SKIP_PUBLISH != 'true' } } steps { script { echo "==========================================" echo "Stage: Publish Package" echo "==========================================" // Get registry URL def registryUrl = npmUtils.getRegistryUrl([ registry: config.registry, repository: config.repository ]) // Extract scope if package is scoped def scope = npmUtils.extractScope(config.packageName) withCredentials([usernamePassword( credentialsId: config.credentialsId, usernameVariable: 'NEXUS_USER', passwordVariable: 'NEXUS_PASS' )]) { // Configure npm registry and authentication sh(""" npm config set registry ${registryUrl} npm config set _auth \$(echo -n "\${NEXUS_USER}:\${NEXUS_PASS}" | base64) npm config fix """) // For scoped packages, configure scope-specific registry if (scope) { sh("npm config set ${scope}:registry ${registryUrl}") echo "Configured registry for scope: ${scope}" } // Publish to Nexus with retry logic def published = false def lastError = null for (int i = 0; i < 3; i++) { try { sh("npm publish") published = true break } catch (Exception e) { lastError = e if (i < 2) { echo "Publish attempt ${i + 1} failed, retrying in 2 seconds..." sleep(2) } } } if (!published) { error("Failed to publish after 3 attempts: ${lastError.message}") } } echo "✓ Successfully published: ${config.packageName}@${env.PACKAGE_VERSION}" giteaUtils.setSuccess("publish") } } } stage('Commit and Push Changes') { when { expression { env.SKIP_PUBLISH != 'true' } } steps { script { echo "==========================================" echo "Stage: Commit and Push Changes" echo "==========================================" // Check git state def gitDir = sh( script: 'git rev-parse --git-dir 2>/dev/null || echo ""', returnStdout: true ).trim() if (!gitDir) { echo "WARNING: Not in a git repository, skipping commit/push" return } withCredentials([usernamePassword( credentialsId: config.gitCredentialsId, usernameVariable: 'GITEA_USER', passwordVariable: 'GITEA_TOKEN' )]) { // Get current remote URL def remoteUrl = originalGitRemote ?: sh( script: 'git config --get remote.origin.url', returnStdout: true ).trim() // Configure git user sh(""" git config user.name "Jenkins" git config user.email "oss@kodeniks.com" """) // Update remote URL to include credentials for authentication sh(""" REMOTE_URL='${remoteUrl}' REPO_PATH=\$(echo "\$REMOTE_URL" | sed 's|.*git\\.kodeniks\\.com/||' | sed 's|\\.git\$||') AUTH_URL="https://\${GITEA_USER}:\${GITEA_TOKEN}@git.kodeniks.com/\$REPO_PATH.git" git remote set-url origin "\$AUTH_URL" """) // Determine which lock files to add def filesToAdd = ["package.json"] if(fileExists("package-lock.json")) { filesToAdd.add("package-lock.json") } else if(fileExists("pnpm-lock.yaml")) { filesToAdd.add("pnpm-lock.yaml") } else if(fileExists("yarn.lock")) { filesToAdd.add("yarn.lock") } // Add files to staging echo "Adding files to git: ${filesToAdd.join(', ')}" sh("git add ${filesToAdd.join(' ')} 2>/dev/null || true") // Check if there are staged changes // Use a simpler approach: check if git diff --staged has any output def stagedDiff = sh( script: 'git diff --staged --name-only', returnStdout: true ).trim() def hasChanges = !stagedDiff.isEmpty() echo "Staged changes check: ${hasChanges ? 'Changes found' : 'No changes'}" if (hasChanges) { echo "Files with changes: ${stagedDiff}" } if (hasChanges) { echo "Committing changes..." sh("git commit -m 'chore: bump version to ${env.PACKAGE_VERSION}'") echo "✓ Committed version bump to ${env.PACKAGE_VERSION}" } else { echo "No changes to commit (version may have been committed already or package.json unchanged)" // Verify what's actually in package.json vs what we expect try { def actualVersion = npmUtils.npmPackageVersion() echo "Current package.json version: ${actualVersion}, expected: ${env.PACKAGE_VERSION}" } catch (Exception e) { echo "Could not read package.json version: ${e.message}" } } // Get branch name with fallbacks def branchName = gitUtils.getBranchName() if (!branchName || branchName.isEmpty() || branchName == 'HEAD') { // Try multiple fallback strategies branchName = sh( script: ''' git rev-parse --abbrev-ref HEAD 2>/dev/null || \ git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null | sed "s|.*/||" || \ echo "master" ''', returnStdout: true ).trim() } echo "Using branch: ${branchName}" // Create tag sh(""" git tag -f v${env.PACKAGE_VERSION} 2>/dev/null || git tag v${env.PACKAGE_VERSION} """) // Push with retry logic def pushSuccess = false def pushError = null for (int i = 0; i < config.gitPushRetries; i++) { try { // Verify remote is accessible sh("git ls-remote origin > /dev/null 2>&1 || true") // Push branch sh("git push origin HEAD:refs/heads/${branchName}") // Push tag sh("git push origin v${env.PACKAGE_VERSION}") // Verify push succeeded by checking if tag exists on remote // grep -q returns exit code 0 if found, 1 if not found def tagCheck = sh( script: "git ls-remote --tags origin v${env.PACKAGE_VERSION} | grep -q v${env.PACKAGE_VERSION}", returnStatus: true ) if (tagCheck != 0) { throw new Exception("Tag verification failed: tag v${env.PACKAGE_VERSION} not found on remote") } echo "✓ Tag v${env.PACKAGE_VERSION} verified on remote" pushSuccess = true break } catch (Exception e) { pushError = e if (i < config.gitPushRetries - 1) { echo "Push attempt ${i + 1} failed: ${e.message}" echo "Retrying in 2 seconds..." sleep(2) } } } if (!pushSuccess) { error("Failed to push to git after ${config.gitPushRetries} attempts: ${pushError.message}") } // Restore original remote URL if (originalGitRemote) { sh("git remote set-url origin '${originalGitRemote}' || true") } } echo "✓ Committed and pushed version ${env.PACKAGE_VERSION} with tag v${env.PACKAGE_VERSION}" giteaUtils.setSuccess("commit-push") } } } } post { always { script { // Cleanup npm config try { sh("npm config delete registry || true") sh("npm config delete _auth || true") // Clean up scoped registry configs def scope = npmUtils.extractScope(config.packageName) if (scope) { sh("npm config delete ${scope}:registry || true") } } catch (Exception e) { echo "Warning: Failed to clean up npm config: ${e.message}" } // Restore git remote if changed try { if (env.GIT_ORIGINAL_REMOTE_URL) { sh("git remote set-url origin '${env.GIT_ORIGINAL_REMOTE_URL}' || true") } } catch (Exception e) { echo "Warning: Failed to restore git remote: ${e.message}" } } } success { script { giteaUtils.setSuccess("pipeline") echo "==========================================" echo "✓ NPM package publish completed successfully!" echo "==========================================" } } failure { script { giteaUtils.setFailed("pipeline") echo "==========================================" echo "✗ Publish failed!" echo "==========================================" } } } } } return this