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