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>
This commit is contained in:
517
vars/publishNpmPackage.groovy
Normal file
517
vars/publishNpmPackage.groovy
Normal file
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* 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
|
||||
Reference in New Issue
Block a user