Jenkins shared library and CDK constructs for AWS infrastructure. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
350 lines
12 KiB
Groovy
350 lines
12 KiB
Groovy
/**
|
|
* 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
|