diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dbe224b6..3b1617b22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - feat(maven): Add maven target to deploy to Maven Central (#258) - feat(symbol-collector): Add symbol-collector target (#266) +- ref(maven): Support BOM files in `maven` target (#270) ## 0.24.4 diff --git a/src/targets/__tests__/maven.test.ts b/src/targets/__tests__/maven.test.ts index 497115ff4..706aeae4e 100644 --- a/src/targets/__tests__/maven.test.ts +++ b/src/targets/__tests__/maven.test.ts @@ -1,7 +1,12 @@ import { homedir } from 'os'; import { join } from 'path'; import { NoneArtifactProvider } from '../../artifact_providers/none'; -import { MavenTarget, targetOptions, targetSecrets } from '../maven'; +import { + MavenTarget, + POM_DEFAULT_FILENAME, + targetOptions, + targetSecrets, +} from '../maven'; import { retrySpawnProcess } from '../../utils/async'; import { withTempDir } from '../../utils/files'; @@ -11,6 +16,11 @@ jest.mock('fs', () => ({ ...jest.requireActual('fs'), promises: { writeFile: jest.fn(() => Promise.resolve()), + readFile: jest.fn((file: string) => file), + readdir: async () => Promise.resolve([]), // empty dir + access: jest.fn().mockImplementation(() => { + // do nothing + }), }, })); @@ -109,10 +119,8 @@ describe('publish', () => { async () => void callOrder.push('closeAndRelease') ); - const version = '1.0.0'; const revision = 'r3v1s10n'; - - await mvnTarget.publish(version, revision); + await mvnTarget.publish('1.0.0', revision); expect(gradlePropsMock).toHaveBeenCalledTimes(1); expect(uploadMock).toHaveBeenCalledTimes(1); expect(uploadMock).toHaveBeenLastCalledWith(revision); @@ -127,7 +135,7 @@ describe('publish', () => { ]); }); - test('upload', async () => { + test('upload POM', async () => { // simple mock to always use the same temporary directory, // instead of creating a new one (withTempDir as jest.MockedFunction).mockImplementation( @@ -143,9 +151,11 @@ describe('publish', () => { mvnTarget.artifactProvider.downloadArtifact = jest .fn() .mockResolvedValueOnce('artifact/download/path'); + mvnTarget.isBomFile = jest.fn().mockResolvedValueOnce(false); await mvnTarget.upload('r3v1s10n'); - expect(retrySpawnProcess).toBeCalledTimes(1); + + expect(retrySpawnProcess).toHaveBeenCalledTimes(1); const callArgs = (retrySpawnProcess as jest.MockedFunction< typeof retrySpawnProcess >).mock.calls[0]; @@ -172,6 +182,49 @@ describe('publish', () => { expect(cmdArgs[8]).toBe('--settings'); expect(cmdArgs[9]).toBe(DEFAULT_OPTION_VALUE); }); + + test('upload BOM', async () => { + // simple mock to always use the same temporary directory, + // instead of creating a new one + (withTempDir as jest.MockedFunction).mockImplementation( + async cb => { + return await cb(tmpDirName); + } + ); + + const mvnTarget = createMavenTarget(); + mvnTarget.getArtifactsForRevision = jest + .fn() + .mockResolvedValueOnce([{ filename: 'mockArtifact.zip' }]); + mvnTarget.artifactProvider.downloadArtifact = jest + .fn() + .mockResolvedValueOnce('artifact/download/path'); + mvnTarget.isBomFile = jest.fn().mockResolvedValueOnce('path/to/bomfile'); + + await mvnTarget.upload('r3v1s10n'); + + expect(retrySpawnProcess).toHaveBeenCalledTimes(1); + const callArgs = (retrySpawnProcess as jest.MockedFunction< + typeof retrySpawnProcess + >).mock.calls[0]; + + expect(callArgs).toHaveLength(2); + expect(callArgs[0]).toEqual(DEFAULT_OPTION_VALUE); + + const cmdArgs = callArgs[1] as string[]; + expect(cmdArgs).toHaveLength(7); + expect(cmdArgs[0]).toBe('gpg:sign-and-deploy-file'); + expect(cmdArgs[1]).toMatch( + new RegExp(`-Dfile=${tmpDirName}.+${POM_DEFAULT_FILENAME}`) + ); + expect(cmdArgs[2]).toMatch( + new RegExp(`-DpomFile=${tmpDirName}.*${POM_DEFAULT_FILENAME}`) + ); + expect(cmdArgs[3]).toBe(`-DrepositoryId=${DEFAULT_OPTION_VALUE}`); + expect(cmdArgs[4]).toBe(`-Durl=${DEFAULT_OPTION_VALUE}`); + expect(cmdArgs[5]).toBe('--settings'); + expect(cmdArgs[6]).toBe(DEFAULT_OPTION_VALUE); + }); }); describe('get gradle home directory', () => { diff --git a/src/targets/maven.ts b/src/targets/maven.ts index fcc6e8098..4d8213084 100644 --- a/src/targets/maven.ts +++ b/src/targets/maven.ts @@ -5,14 +5,14 @@ import { } from '../artifact_providers/base'; import { BaseTarget } from './base'; import { homedir } from 'os'; -import { basename, join, parse } from 'path'; -import { promises as fsPromises } from 'fs'; +import { basename, extname, join, parse } from 'path'; +import { constants as fsConstants, promises as fsPromises } from 'fs'; import { checkExecutableIsPresent, extractZipArchive } from '../utils/system'; import { retrySpawnProcess } from '../utils/async'; import { withTempDir } from '../utils/files'; -import { checkEnvForPrerequisite } from '../utils/env'; import { ConfigurationError } from '../utils/errors'; import { stringToRegexp } from '../utils/filters'; +import { checkEnvForPrerequisite } from '../utils/env'; const GRADLE_PROPERTIES_FILENAME = 'gradle.properties'; @@ -21,6 +21,9 @@ const GRADLE_PROPERTIES_FILENAME = 'gradle.properties'; * https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_environment_variables */ const DEFAULT_GRADLE_USER_HOME = join(homedir(), '.gradle'); +export const POM_DEFAULT_FILENAME = 'pom-default.xml'; +const POM_FILE_EXT = '.xml'; // Must include the leading `.` +const BOM_FILE_KEY_REGEXP = stringToRegexp('/pom/'); export const targetSecrets = ['OSSRH_USERNAME', 'OSSRH_PASSWORD'] as const; type SecretsType = typeof targetSecrets[number]; @@ -258,12 +261,95 @@ export class MavenTarget extends BaseTarget { * @param distDir directory of the distribution. */ private async uploadDistribution(distDir: string): Promise { + const bomFile = await this.getBomFileInDist(distDir); + if (bomFile) { + this.logger.debug('Found BOM: ', bomFile); + await this.uploadBomDistribution(bomFile); + } else { + this.logger.debug('Did not find a BOM.'); + await this.uploadPomDistribution(distDir); + } + } + + /** + * Returns the path to the BOM file in the given distribution directory, and + * `undefined` if there isn't any. + */ + private async getBomFileInDist(distDir: string): Promise { + const pomFilepath = join(distDir, POM_DEFAULT_FILENAME); + if (await this.isBomFile(pomFilepath)) { + return pomFilepath; + } + + // There may be several files in the ZIP-ed artifact with the same name, + // where the BOM may be one of them (there may not be a BOM). Files may be + // renamed when extracting the ZIP, so the default name (`pom-default.xml`) + // may not match. It's assumed that any renaming keeps the same extension, + // so all files with the same extension are checked to identify the BOM. + // TODO: make sure all scenarios are considered and tested. + // Each file system may handle this case differently, and attended vs + // unattended mode also have different behaviours. It's not desired to get + // the BOM renamed in such a way that isn't handled by the target. + const filesInDir = await fsPromises.readdir(distDir); + const potentialPoms = filesInDir + .filter(f => extname(f) === POM_FILE_EXT) + .filter(f => f !== POM_DEFAULT_FILENAME) + .map(f => join(distDir, f)); + + for (const f of potentialPoms) { + if (await this.isBomFile(f)) { + return f; + } + } + + return undefined; + } + + /** + * Returns whether the given POM is a BOM. + * + * A BOM file is a POM file with the following key: + * `pom`, usually named as `pom-default.xml`. + * + * @param pomFilepath path to the POM. + * @returns true if the POM is a BOM. + */ + public async isBomFile(pomFilepath: string): Promise { + try { + await fsPromises.access(pomFilepath, fsConstants.R_OK); + const fileContents = await fsPromises.readFile(pomFilepath, { + encoding: 'utf8', + }); + return BOM_FILE_KEY_REGEXP.test(fileContents); + } catch (error) { + this.logger.warn( + `Could not determine if path corresponds to a BOM file: ${pomFilepath}\n` + + 'Error:\n', + error + ); + return false; + } + } + + private async uploadBomDistribution(bomFile: string): Promise { + await retrySpawnProcess(this.mavenConfig.mavenCliPath, [ + 'gpg:sign-and-deploy-file', + `-Dfile=${bomFile}`, + `-DpomFile=${bomFile}`, + `-DrepositoryId=${this.mavenConfig.mavenRepoId}`, + `-Durl=${this.mavenConfig.mavenRepoUrl}`, + '--settings', + this.mavenConfig.mavenSettingsPath, + ]); + } + + private async uploadPomDistribution(distDir: string): Promise { const { targetFile, javadocFile, sourcesFile, pomFile, - } = this.getFilesForMavenCli(distDir); + } = this.getFilesForMavenPomDist(distDir); // Maven central is very flaky, so retrying with an exponential delay in // in case it fails. @@ -288,7 +374,7 @@ export class MavenTarget extends BaseTarget { * @param distDir directory of the distribution. * @returns record of required files. */ - private getFilesForMavenCli(distDir: string): Record { + private getFilesForMavenPomDist(distDir: string): Record { const moduleName = parse(distDir).base; return { targetFile: join(distDir, this.getTargetFilename(distDir)),