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:
349
vars/npmUtils.groovy
Normal file
349
vars/npmUtils.groovy
Normal file
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* NPM utilities for Spicy CDK pipelines
|
||||
* Publishes packages to Nexus NPM repository
|
||||
*
|
||||
* 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.
|
||||
* - pnpmVersion: '8', '9', '10', etc.
|
||||
* - useContainer: true/false to opt-in to containerized builds
|
||||
*/
|
||||
|
||||
// Default Nexus configuration
|
||||
def getDefaultConfig() {
|
||||
return [
|
||||
registry: 'nexus.kodeniks.com',
|
||||
repository: 'npm-hosted',
|
||||
credentialsId: 'kodeniks-nexus-repository'
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry an operation with exponential backoff
|
||||
* @param maxRetries - Maximum number of retry attempts (default: 3)
|
||||
* @param operation - Closure to execute
|
||||
* @return Result of the operation
|
||||
*/
|
||||
def retryWithBackoff(int maxRetries = 3, Closure operation) {
|
||||
def lastError
|
||||
for (int i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return operation()
|
||||
} catch (Exception e) {
|
||||
lastError = e
|
||||
if (i < maxRetries - 1) {
|
||||
def delay = (i + 1) * 2 // 2s, 4s, 6s
|
||||
echo "Attempt ${i + 1}/${maxRetries} failed: ${e.message}"
|
||||
echo "Retrying in ${delay} seconds..."
|
||||
sleep(delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Exception("Operation failed after ${maxRetries} attempts: ${lastError.message}", lastError)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a field from package.json using Node.js
|
||||
* @param field - Field name to read (e.g., 'name', 'version')
|
||||
* @return Field value as string
|
||||
*/
|
||||
def readPackageJsonField(String field) {
|
||||
if (!fileExists('package.json')) {
|
||||
error("package.json not found")
|
||||
}
|
||||
return sh(
|
||||
script: "node -pe \"require('./package.json').${field}\"",
|
||||
returnStdout: true
|
||||
).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate package.json exists and has required fields
|
||||
* @throws error if validation fails
|
||||
*/
|
||||
def validatePackageJson() {
|
||||
if (!fileExists('package.json')) {
|
||||
error("package.json not found in workspace")
|
||||
}
|
||||
|
||||
try {
|
||||
// Read fields using Node.js
|
||||
def pkgName = readPackageJsonField('name')
|
||||
def pkgVersion = readPackageJsonField('version')
|
||||
|
||||
if (!pkgName || pkgName.isEmpty() || pkgName == 'undefined') {
|
||||
error("package.json missing required 'name' field")
|
||||
}
|
||||
|
||||
if (!pkgVersion || pkgVersion.isEmpty() || pkgVersion == 'undefined') {
|
||||
error("package.json missing required 'version' field")
|
||||
}
|
||||
|
||||
// Validate version format (semver: x.y.z or x.y.z-prerelease)
|
||||
if (!pkgVersion.matches(/^\d+\.\d+\.\d+(-.*)?$/)) {
|
||||
error("Invalid version format in package.json: ${pkgVersion}. Expected semver format (e.g., 1.0.0)")
|
||||
}
|
||||
|
||||
echo "✓ package.json validation passed: ${pkgName}@${pkgVersion}"
|
||||
return [name: pkgName, version: pkgVersion]
|
||||
} catch (Exception e) {
|
||||
error("Failed to parse package.json: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that version was correctly updated in package.json
|
||||
* @param expectedVersion - The version that should be in package.json
|
||||
* @throws error if version doesn't match
|
||||
*/
|
||||
def verifyVersionUpdate(String expectedVersion) {
|
||||
def actualVersion = npmPackageVersion()
|
||||
|
||||
if (actualVersion != expectedVersion) {
|
||||
error("Version mismatch: expected ${expectedVersion}, but package.json has ${actualVersion}")
|
||||
}
|
||||
|
||||
// Also verify by reading version directly from package.json using Node.js
|
||||
try {
|
||||
def actualVersionFromFile = readPackageJsonField('version')
|
||||
if (actualVersionFromFile != expectedVersion) {
|
||||
error("Version in package.json (${actualVersionFromFile}) doesn't match expected (${expectedVersion})")
|
||||
}
|
||||
} catch (Exception e) {
|
||||
echo "WARNING: Could not verify version via package.json read: ${e.message}"
|
||||
}
|
||||
|
||||
echo "✓ Version verification passed: ${expectedVersion}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full registry URL for npm
|
||||
* @param args.registry - Nexus registry URL (default: 'nexus.kodeniks.com')
|
||||
* @param args.repository - Nexus repository name (default: 'npm-hosted')
|
||||
*/
|
||||
def getRegistryUrl(Map args = [:]) {
|
||||
def config = getDefaultConfig() + args
|
||||
return "https://${config.registry}/repository/${config.repository}/"
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract scope from package name (e.g., '@kodeniks/package' -> '@kodeniks')
|
||||
* @param packageName - Package name (may include scope)
|
||||
*/
|
||||
def extractScope(String packageName) {
|
||||
if (packageName && packageName.startsWith('@')) {
|
||||
def parts = packageName.split('/')
|
||||
return parts.length > 0 ? parts[0] : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get package name from package.json
|
||||
* @return Package name
|
||||
*/
|
||||
def npmPackageName() {
|
||||
if (!fileExists('package.json')) {
|
||||
error("package.json not found")
|
||||
}
|
||||
return sh(
|
||||
script: 'node -pe "require(\'./package.json\').name"',
|
||||
returnStdout: true
|
||||
).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get package version from package.json
|
||||
* @return Package version
|
||||
*/
|
||||
def npmPackageVersion() {
|
||||
if (!fileExists('package.json')) {
|
||||
error("package.json not found")
|
||||
}
|
||||
return sh(
|
||||
script: 'node -pe "require(\'./package.json\').version"',
|
||||
returnStdout: true
|
||||
).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure version is ready for publishing
|
||||
* Checks if current version is published on Nexus, auto-bumps patch if needed
|
||||
* Includes retry logic for network operations
|
||||
* @param args.packageName - Package name (required)
|
||||
* @param args.registry - Nexus registry URL (default: 'nexus.kodeniks.com')
|
||||
* @param args.repository - Nexus repository name (default: 'npm-hosted')
|
||||
* @param args.credentialsId - Jenkins credentials ID for Nexus (default: 'kodeniks-nexus-repository')
|
||||
* @param args.maxRetries - Maximum retry attempts for network calls (default: 3)
|
||||
* @return The version to publish
|
||||
*/
|
||||
def ensureVersion(Map args = [:]) {
|
||||
def config = getDefaultConfig() + args
|
||||
def packageName = args.packageName
|
||||
def maxRetries = args.maxRetries ?: 3
|
||||
|
||||
if (!packageName) {
|
||||
error("packageName is required for ensureVersion")
|
||||
}
|
||||
|
||||
// Validate package.json first
|
||||
validatePackageJson()
|
||||
|
||||
def registryUrl = getRegistryUrl(config)
|
||||
def currentVersion = npmPackageVersion()
|
||||
|
||||
echo "=========================================="
|
||||
echo "Version Check for: ${packageName}"
|
||||
echo "Current version: ${currentVersion}"
|
||||
echo "Registry: ${registryUrl}"
|
||||
echo "=========================================="
|
||||
|
||||
def versionOutput
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: config.credentialsId,
|
||||
usernameVariable: 'NEXUS_USER',
|
||||
passwordVariable: 'NEXUS_PASS'
|
||||
)]) {
|
||||
// Build package path for API URL (scoped: @scope/package -> @scope%2Fpackage)
|
||||
def packagePath = packageName.startsWith('@')
|
||||
? packageName.replace('/', '%2F')
|
||||
: packageName
|
||||
def apiUrl = "${registryUrl}${packagePath}"
|
||||
|
||||
echo "Fetching from: ${apiUrl}"
|
||||
|
||||
// Run version check with retry logic using curl + jq
|
||||
versionOutput = retryWithBackoff(maxRetries) {
|
||||
def output = sh(
|
||||
script: """
|
||||
# Get current version from package.json
|
||||
CURRENT_VERSION=\$(jq -r '.version' package.json)
|
||||
|
||||
# Fetch package metadata from Nexus
|
||||
RESPONSE=\$(curl -s -w "\\n%{http_code}" \\
|
||||
-H "Accept: application/json" \\
|
||||
-u "\${NEXUS_USER}:\${NEXUS_PASS}" \\
|
||||
"${apiUrl}")
|
||||
|
||||
HTTP_CODE=\$(echo "\$RESPONSE" | tail -n1)
|
||||
BODY=\$(echo "\$RESPONSE" | sed '\$d')
|
||||
|
||||
if [ "\$HTTP_CODE" = "200" ]; then
|
||||
# Extract published version (prefer dist-tags.latest, fallback to highest version)
|
||||
PUBLISHED_VERSION=\$(echo "\$BODY" | jq -r 'if ."dist-tags".latest then ."dist-tags".latest else (.versions | keys | sort_by(. | split(".") | map(tonumber)) | reverse | .[0]) end')
|
||||
|
||||
if [ "\$PUBLISHED_VERSION" = "null" ] || [ -z "\$PUBLISHED_VERSION" ]; then
|
||||
PUBLISHED_VERSION=""
|
||||
fi
|
||||
|
||||
echo "Published version found: \${PUBLISHED_VERSION}, current: \${CURRENT_VERSION}" >&2
|
||||
|
||||
if [ -n "\$PUBLISHED_VERSION" ]; then
|
||||
# Compare versions using semver
|
||||
# If current == published, or current < published, we need to bump from published
|
||||
# If current > published, we can use current as-is
|
||||
|
||||
# Check if versions are equal (exact string match)
|
||||
if [ "\$CURRENT_VERSION" = "\$PUBLISHED_VERSION" ]; then
|
||||
# Equal - bump from published
|
||||
BASE_VERSION="\${PUBLISHED_VERSION}"
|
||||
NEW_VERSION=\$(semver -i patch "\$BASE_VERSION")
|
||||
else
|
||||
# Compare versions using semver-aware sorting
|
||||
# sort -V handles version sorting correctly (1.0.1 < 1.0.11 < 1.1.0)
|
||||
# Get both versions, sort them, highest comes last
|
||||
SORTED=\$(printf "%s\\n%s" "\$CURRENT_VERSION" "\$PUBLISHED_VERSION" | sort -V)
|
||||
HIGHEST=\$(echo "\$SORTED" | tail -n1)
|
||||
LOWEST=\$(echo "\$SORTED" | head -n1)
|
||||
|
||||
# Verify sort worked correctly (should have exactly 2 versions)
|
||||
if [ "\$(echo "\$SORTED" | wc -l)" != "2" ]; then
|
||||
echo "ERROR: Version sorting failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "\$HIGHEST" = "\$CURRENT_VERSION" ] && [ "\$LOWEST" = "\$PUBLISHED_VERSION" ]; then
|
||||
# Current > Published - no bump needed, use current
|
||||
BASE_VERSION="\${CURRENT_VERSION}"
|
||||
NEW_VERSION="\${CURRENT_VERSION}"
|
||||
elif [ "\$HIGHEST" = "\$PUBLISHED_VERSION" ] && [ "\$LOWEST" = "\$CURRENT_VERSION" ]; then
|
||||
# Published > Current - bump from published
|
||||
BASE_VERSION="\${PUBLISHED_VERSION}"
|
||||
NEW_VERSION=\$(semver -i patch "\$BASE_VERSION")
|
||||
else
|
||||
# Should not happen, but fallback to bumping from published
|
||||
echo "WARNING: Unexpected version comparison result" >&2
|
||||
BASE_VERSION="\${PUBLISHED_VERSION}"
|
||||
NEW_VERSION=\$(semver -i patch "\$BASE_VERSION")
|
||||
fi
|
||||
fi
|
||||
|
||||
# Update package.json if version changed
|
||||
if [ "\$NEW_VERSION" != "\$CURRENT_VERSION" ]; then
|
||||
jq --arg v "\$NEW_VERSION" '.version = \$v' package.json > package.json.tmp && mv package.json.tmp package.json
|
||||
echo "Bumped version from \${CURRENT_VERSION} to \${NEW_VERSION}" >&2
|
||||
fi
|
||||
|
||||
echo "\$NEW_VERSION"
|
||||
|
||||
# Update package.json
|
||||
jq --arg v "\$NEW_VERSION" '.version = \$v' package.json > package.json.tmp && mv package.json.tmp package.json
|
||||
|
||||
echo "Bumped version from \${BASE_VERSION} to \${NEW_VERSION}" >&2
|
||||
echo "\$NEW_VERSION"
|
||||
else
|
||||
# No published version, use current
|
||||
echo "\$CURRENT_VERSION"
|
||||
fi
|
||||
elif [ "\$HTTP_CODE" = "404" ]; then
|
||||
echo "Package not found (404), using current version" >&2
|
||||
echo "\$CURRENT_VERSION"
|
||||
else
|
||||
echo "Error: HTTP \$HTTP_CODE" >&2
|
||||
echo "\$BODY" >&2
|
||||
exit 1
|
||||
fi
|
||||
""",
|
||||
returnStdout: true
|
||||
).trim()
|
||||
|
||||
if (!output || output.isEmpty()) {
|
||||
throw new Exception("Version check returned empty output")
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
// Verify the version was actually updated in package.json
|
||||
def actualVersion = npmPackageVersion()
|
||||
if (versionOutput != actualVersion) {
|
||||
echo "WARNING: Version mismatch detected!"
|
||||
echo " Script returned: ${versionOutput}"
|
||||
echo " package.json has: ${actualVersion}"
|
||||
echo " Attempting to use package.json version..."
|
||||
|
||||
// If script said to bump but package.json wasn't updated, try to sync
|
||||
if (versionOutput != currentVersion && actualVersion == currentVersion) {
|
||||
echo "Version bump didn't persist. This may indicate a file system issue."
|
||||
error("Version update failed: script returned ${versionOutput} but package.json still has ${actualVersion}")
|
||||
}
|
||||
|
||||
versionOutput = actualVersion
|
||||
}
|
||||
}
|
||||
|
||||
if (!versionOutput || versionOutput.isEmpty()) {
|
||||
error("Failed to get version. The version check returned empty.")
|
||||
}
|
||||
|
||||
// Final verification
|
||||
verifyVersionUpdate(versionOutput)
|
||||
|
||||
echo "=========================================="
|
||||
echo "✓ Version to publish: ${versionOutput}"
|
||||
echo "=========================================="
|
||||
|
||||
return versionOutput
|
||||
}
|
||||
|
||||
return this
|
||||
Reference in New Issue
Block a user