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:
2025-11-18 22:21:00 -08:00
commit 68684df471
51 changed files with 15587 additions and 0 deletions

349
vars/npmUtils.groovy Normal file
View 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