Files
spicy-automation/vars/publishNpmPackage.groovy
Ryan Wilson 68684df471 Initial commit: Spicy CDK automation framework
Jenkins shared library and CDK constructs for AWS infrastructure.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-11-18 22:21:00 -08:00

518 lines
18 KiB
Groovy

/**
* 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