diff --git a/packages/core/src/auto.ts b/packages/core/src/auto.ts index 4af2cdefa..2060a8f90 100644 --- a/packages/core/src/auto.ts +++ b/packages/core/src/auto.ts @@ -307,7 +307,7 @@ const loadEnv = () => { }; /** Get the pr number from user input or the CI env. */ -function getPrNumberFromEnv(pr?: number) { +export function getPrNumberFromEnv(pr?: number) { const envPr = "pr" in env && Number(env.pr); const prNumber = pr || envPr; diff --git a/plugins/cocoapods/__tests__/cocoapods.test.ts b/plugins/cocoapods/__tests__/cocoapods.test.ts index 094b1e7d7..c327ee79b 100644 --- a/plugins/cocoapods/__tests__/cocoapods.test.ts +++ b/plugins/cocoapods/__tests__/cocoapods.test.ts @@ -7,9 +7,14 @@ import CocoapodsPlugin, { getParsedPodspecContents, getVersion, updatePodspecVersion, + updateSourceLocation, + getSourceInfo, } from "../src"; -const specWithVersion = (version: string) => ` +const specWithVersion = ( + version: string, + source = "{ :git => 'https://github.com/intuit/auto.git', :tag => s.version.to_s }" +) => ` Pod:: Spec.new do | s | s.name = 'Test' s.version = '${version}' @@ -29,7 +34,7 @@ const specWithVersion = (version: string) => ` # s.screenshots = 'www.example.com/screenshots_1', 'www.example.com/screenshots_2' s.license = { : type => 'MIT', : file => 'LICENSE' } s.author = { 'hborawski' => 'harris_borawski@intuit.com' } - s.source = { : git => 'https://github.com/intuit/auto.git', : tag => s.version.to_s } + s.source = ${source} s.ios.deployment_target = '11.0' @@ -46,11 +51,12 @@ const mockPodspec = (contents: string) => { return jest.spyOn(utilities, "getPodspecContents").mockReturnValue(contents); }; -let exec = jest.fn().mockResolvedValueOnce(""); +let exec = jest.fn(); // @ts-ignore jest.mock("../../../packages/core/dist/utils/exec-promise", () => (...args) => exec(...args) ); +const logger = dummyLog(); describe("Cocoapods Plugin", () => { let hooks: Auto.IAutoHooks; @@ -67,7 +73,25 @@ describe("Cocoapods Plugin", () => { exec.mockClear(); const plugin = new CocoapodsPlugin(options); hooks = makeHooks(); - plugin.apply({ hooks, logger: dummyLog(), prefixRelease } as Auto.Auto); + plugin.apply(({ + hooks, + logger: logger, + prefixRelease, + git: { + getLatestRelease: async () => "0.0.1", + getPullRequest: async () => ({ + data: { + head: { + repo: { + clone_url: "https://github.com/intuit-fork/auto.git", + }, + }, + }, + }), + }, + remote: "https://github.com/intuit/auto.git", + getCurrentVersion: async () => "0.0.1", + } as unknown) as Auto.Auto); }); describe("getParsedPodspecContents", () => { @@ -93,6 +117,25 @@ describe("Cocoapods Plugin", () => { expect(getVersion("./Test.podspec")).toBe("0.0.1"); }); + test("should return canary version", () => { + mockPodspec(specWithVersion("0.0.1-canary.1.0.0")); + + expect(getVersion("./Test.podspec")).toBe("0.0.1-canary.1.0.0"); + }); + }); + describe("getSourceInfo", () => { + test("should throw error if source line cant be found", () => { + mockPodspec(specWithVersion("0.0.1", "no source")); + + expect(() => getSourceInfo("./Test.podspec")).toThrow(); + }); + test("should retrieve source info", () => { + mockPodspec(specWithVersion("0.0.1")); + + expect(getSourceInfo("./Test.podspec")).toBe( + "{ :git => 'https://github.com/intuit/auto.git', :tag => s.version.to_s }" + ); + }); }); describe("updatePodspecVersion", () => { test("should throw error if there is an error writing file", async () => { @@ -104,11 +147,9 @@ describe("Cocoapods Plugin", () => { throw new Error("Filesystem Error"); }); - await expect( - updatePodspecVersion("./Test.podspec", "0.0.2") - ).rejects.toThrowError( - "Error updating version in podspec: ./Test.podspec" - ); + expect( + updatePodspecVersion.bind(null, "./Test.podspec", "0.0.2") + ).toThrowError("Error updating version in podspec: ./Test.podspec"); }); test("should successfully write new version", async () => { mockPodspec(specWithVersion("0.0.1")); @@ -119,6 +160,47 @@ describe("Cocoapods Plugin", () => { expect(mock).lastCalledWith(expect.any(String), specWithVersion("0.0.2")); }); }); + describe("updateSourceLocation", () => { + test("should throw error if there is an error writing file", async () => { + mockPodspec(specWithVersion("0.0.1")); + + exec.mockReturnValue("commithash"); + + jest + .spyOn(utilities, "writePodspecContents") + .mockImplementationOnce(() => { + throw new Error("Filesystem Error"); + }); + + await expect( + updateSourceLocation( + "./Test.podspec", + "https://github.com/somefork/auto.git" + ) + ).rejects.toThrowError( + "Error updating source location in podspec: ./Test.podspec" + ); + }); + test("should successfully write new source location", async () => { + mockPodspec(specWithVersion("0.0.1")); + + const mock = jest.spyOn(utilities, "writePodspecContents"); + + exec.mockReturnValue(Promise.resolve("commithash")); + + await updateSourceLocation( + "./Test.podspec", + "https://github.com/somefork/auto.git" + ); + expect(mock).lastCalledWith( + expect.any(String), + specWithVersion( + "0.0.1", + "{ :git => 'https://github.com/somefork/auto.git', :commit => 'commithash' }" + ) + ); + }); + }); describe("modifyConfig hook", () => { test("should set noVersionPrefix to true", () => { const config = {}; @@ -143,6 +225,32 @@ describe("Cocoapods Plugin", () => { }); }); describe("version hook", () => { + test("should do nothing on dryRun", async () => { + mockPodspec(specWithVersion("0.0.1")); + + const mockLog = jest.spyOn(logger.log, "info"); + + await hooks.version.promise({ bump: Auto.SEMVER.patch, dryRun: true }); + + expect(exec).toHaveBeenCalledTimes(0); + expect(mockLog).toHaveBeenCalledTimes(1); + }); + test("should not use logger on quiet dryRun", async () => { + mockPodspec(specWithVersion("0.0.1")); + + const mockLog = jest.spyOn(logger.log, "info"); + const mockConsole = jest.spyOn(console, "log"); + + await hooks.version.promise({ + bump: Auto.SEMVER.patch, + dryRun: true, + quiet: true, + }); + + expect(exec).toHaveBeenCalledTimes(0); + expect(mockLog).toHaveBeenCalledTimes(0); + expect(mockConsole).toHaveBeenCalledTimes(1); + }); test("should version release - patch version", async () => { mockPodspec(specWithVersion("0.0.1")); @@ -175,8 +283,10 @@ describe("Cocoapods Plugin", () => { const mock = jest .spyOn(utilities, "writePodspecContents") - .mockImplementationOnce(() => { - throw new Error("Filesystem Error"); + .mockImplementation((path, contents) => { + if (contents.includes("1.0.0")) { + throw new Error("Filesystem Error"); + } }); await expect( @@ -235,6 +345,99 @@ describe("Cocoapods Plugin", () => { }); }); + describe("canary hook", () => { + test("should do nothing on dryRun", async () => { + mockPodspec(specWithVersion("0.0.1")); + jest.spyOn(Auto, "getPrNumberFromEnv").mockReturnValue(1); + + const mockLog = jest.spyOn(logger.log, "info"); + + await hooks.canary.promise({ + bump: Auto.SEMVER.patch, + canaryIdentifier: "canary.1.0", + dryRun: true, + }); + + expect(exec).toHaveBeenCalledTimes(0); + expect(mockLog).toHaveBeenCalledTimes(1); + }); + test("should not use logger on quiet dryRun", async () => { + mockPodspec(specWithVersion("0.0.1")); + jest.spyOn(Auto, "getPrNumberFromEnv").mockReturnValue(1); + + const mockLog = jest.spyOn(logger.log, "info"); + const mockConsole = jest.spyOn(console, "log"); + + await hooks.canary.promise({ + bump: Auto.SEMVER.patch, + canaryIdentifier: "canary.1.0", + dryRun: true, + quiet: true, + }); + + expect(exec).toHaveBeenCalledTimes(0); + expect(mockLog).toHaveBeenCalledTimes(0); + expect(mockConsole).toHaveBeenCalledTimes(1); + }); + test("should tag with canary version", async () => { + jest.spyOn(Auto, "getPrNumberFromEnv").mockReturnValue(1); + let podSpec = specWithVersion("0.0.1"); + jest + .spyOn(utilities, "getPodspecContents") + .mockImplementation(() => podSpec); + const mock = jest + .spyOn(utilities, "writePodspecContents") + .mockImplementation((path, contents) => { + podSpec = contents; + }); + + const newVersion = await hooks.canary.promise({ + bump: "minor" as Auto.SEMVER, + canaryIdentifier: "canary.1.1.1", + }); + + expect(newVersion).toBe("0.1.0-canary.1.1.1"); + expect(exec).toBeCalledTimes(3); + expect(exec).toHaveBeenCalledWith("git", ["checkout", "./Test.podspec"]); + + expect(mock).toHaveBeenLastCalledWith( + expect.any(String), + specWithVersion( + "0.1.0-canary.1.1.1", + "{ :git => 'https://github.com/intuit-fork/auto.git', :commit => 'undefined' }" + ) + ); + }); + test("should tag with canary version with no PR number", async () => { + let podSpec = specWithVersion("0.0.1"); + jest + .spyOn(utilities, "getPodspecContents") + .mockImplementation(() => podSpec); + const mock = jest + .spyOn(utilities, "writePodspecContents") + .mockImplementation((path, contents) => { + podSpec = contents; + }); + + const newVersion = await hooks.canary.promise({ + bump: "minor" as Auto.SEMVER, + canaryIdentifier: "canary.1.1.1", + }); + + expect(newVersion).toBe("0.1.0-canary.1.1.1"); + expect(exec).toBeCalledTimes(3); + expect(exec).toHaveBeenCalledWith("git", ["checkout", "./Test.podspec"]); + + expect(mock).toHaveBeenLastCalledWith( + expect.any(String), + specWithVersion( + "0.1.0-canary.1.1.1", + "{ :git => 'https://github.com/intuit/auto.git', :commit => 'undefined' }" + ) + ); + }); + }); + describe("publish hook", () => { test("should push to trunk if no specsRepo in options", async () => { mockPodspec(specWithVersion("0.0.1")); diff --git a/plugins/cocoapods/src/index.ts b/plugins/cocoapods/src/index.ts index 5d5109e28..da2512cd0 100644 --- a/plugins/cocoapods/src/index.ts +++ b/plugins/cocoapods/src/index.ts @@ -3,6 +3,8 @@ import { IPlugin, execPromise, validatePluginConfiguration, + ILogger, + getPrNumberFromEnv, } from "@auto-it/core"; import { inc, ReleaseType } from "semver"; @@ -14,7 +16,11 @@ import { getPodspecContents, writePodspecContents } from "./utilities"; const logPrefix = "[Cocoapods-Plugin]"; /** Regex used to pull the version line from the spec */ -const versionRegex = /\.version\s*=\s*['|"](?\d+\.\d+\.\d+)['|"]/; +const versionRegex = /\.version\s*=\s*['|"](?\d+\.\d+\.\d+.*?)['|"]/; + +/** Regex used to pull the source dictionary from the spec */ +const sourceLineRegex = /\.source.*(?\{\s*:\s*git.*\})/; + /** * Wrapper to add logPrefix to messages * @@ -69,16 +75,27 @@ export function getVersion(podspecPath: string): string { throw new Error(`Version could not be found in podspec: ${podspecPath}`); } +/** + * Retrieves the source dictionary currently in the podspec file + * + * @param podspecPath - The relative path to the podspec file + */ +export function getSourceInfo(podspecPath: string): string { + const podspecContents = sourceLineRegex.exec(getPodspecContents(podspecPath)); + if (podspecContents?.groups?.source) { + return podspecContents.groups.source; + } + + throw new Error(`Source could not be found in podspec: ${podspecPath}`); +} + /** * Updates the version in the podspec to the supplied version * * @param podspecPath - The relative path to the podspec file * @param version - The version to update the podspec to */ -export async function updatePodspecVersion( - podspecPath: string, - version: string -) { +export function updatePodspecVersion(podspecPath: string, version: string) { const previousVersion = getVersion(podspecPath); const parsedContents = getParsedPodspecContents(podspecPath); const podspecContents = getPodspecContents(podspecPath); @@ -95,24 +112,49 @@ export async function updatePodspecVersion( ); writePodspecContents(podspecPath, newPodspec); - - await execPromise("git", [ - "commit", - "-am", - `"update version: ${version} [skip ci]"`, - "--no-verify", - ]); } } catch (error) { throw new Error(`Error updating version in podspec: ${podspecPath}`); } } +/** + * Updates the source location to point to the current commit for the given remote + * + * @param podspecPath - The relative path to the podspec file + * @param remote - The git remote that is being used + */ +export async function updateSourceLocation( + podspecPath: string, + remote: string +) { + const podspecContents = getPodspecContents(podspecPath); + + const source = getSourceInfo(podspecPath); + + try { + const revision = await execPromise("git", ["rev-parse", "HEAD"]); + const newPodspec = podspecContents.replace( + source, + `{ :git => '${remote}', :commit => '${revision}' }` + ); + + writePodspecContents(podspecPath, newPodspec); + } catch (error) { + throw new Error( + `Error updating source location in podspec: ${podspecPath}` + ); + } +} + /** Use auto to version your cocoapod */ export default class CocoapodsPlugin implements IPlugin { /** The name of the plugin */ name = "cocoapods"; + /** The auto logger */ + logger?: ILogger; + /** The options of the plugin */ readonly options: ICocoapodsPluginOptions; @@ -123,6 +165,7 @@ export default class CocoapodsPlugin implements IPlugin { /** Tap into auto plugin points. */ apply(auto: Auto) { + this.logger = auto.logger; const isQuiet = auto.logger.logLevel === "quiet"; const isVerbose = auto.logger.logLevel === "verbose" || @@ -144,34 +187,95 @@ export default class CocoapodsPlugin implements IPlugin { auto.prefixRelease(getVersion(this.options.podspecPath)) ); - auto.hooks.version.tapPromise(this.name, async ({ bump, dryRun, quiet }) => { - const previousVersion = getVersion(this.options.podspecPath); - const releaseVersion = inc(previousVersion, bump as ReleaseType); + auto.hooks.version.tapPromise( + this.name, + async ({ bump, dryRun, quiet }) => { + const previousVersion = getVersion(this.options.podspecPath); + const releaseVersion = inc(previousVersion, bump as ReleaseType); + + if (dryRun && releaseVersion) { + if (quiet) { + console.log(releaseVersion); + } else { + auto.logger.log.info(`Would have published: ${releaseVersion}`); + } - if (dryRun && releaseVersion) { - if (quiet) { - console.log(releaseVersion); - } else { - auto.logger.log.info(`Would have published: ${releaseVersion}`); + return; } - return; - } + if (!releaseVersion) { + throw new Error( + `Could not increment previous version: ${previousVersion}` + ); + } + + updatePodspecVersion(this.options.podspecPath, releaseVersion); + + await execPromise("git", [ + "commit", + "-am", + `"update version: ${releaseVersion} [skip ci]"`, + "--no-verify", + ]); - if (!releaseVersion) { - throw new Error( - `Could not increment previous version: ${previousVersion}` - ); + await execPromise("git", [ + "tag", + releaseVersion, + "-m", + `"Update version to ${releaseVersion}"`, + ]); } + ); - await updatePodspecVersion(this.options.podspecPath, releaseVersion); - await execPromise("git", [ - "tag", - releaseVersion, - "-m", - `"Update version to ${releaseVersion}"`, - ]); - }); + auto.hooks.canary.tapPromise( + this.name, + async ({ bump, canaryIdentifier, dryRun, quiet }) => { + if (!auto.git) { + return; + } + + const pr = getPrNumberFromEnv(); + + if (!pr) { + this.logger?.log.info( + logMessage( + `No PR number found, using ${auto.remote} as the remote for canary. Commit must be pushed for this to work.` + ) + ); + } + + const remoteRepo = pr + ? await (await auto.git.getPullRequest(pr)).data.head.repo.clone_url + : auto.remote; + + const lastRelease = await auto.git.getLatestRelease(); + const current = await auto.getCurrentVersion(lastRelease); + const nextVersion = inc(current, bump as ReleaseType); + const canaryVersion = `${nextVersion}-${canaryIdentifier}`; + + if (dryRun) { + if (quiet) { + console.log(canaryVersion); + } else { + auto.logger.log.info(`Would have published: ${canaryVersion}`); + } + + return; + } + + await updateSourceLocation(this.options.podspecPath, remoteRepo); + + updatePodspecVersion(this.options.podspecPath, canaryVersion); + + // Publish the canary podspec, committing it isn't needed for specs push + await this.publishPodSpec(podLogLevel); + + // Reset changes to podspec file since it doesn't need to be committed + await execPromise("git", ["checkout", this.options.podspecPath]); + + return canaryVersion; + } + ); auto.hooks.beforeShipIt.tapPromise(this.name, async ({ dryRun }) => { if (dryRun) { @@ -190,8 +294,6 @@ export default class CocoapodsPlugin implements IPlugin { }); auto.hooks.publish.tapPromise(this.name, async () => { - const [pod, ...commands] = this.options.podCommand?.split(" ") || ["pod"]; - await execPromise("git", [ "push", "--follow-tags", @@ -199,73 +301,36 @@ export default class CocoapodsPlugin implements IPlugin { auto.remote, auto.baseBranch, ]); + await this.publishPodSpec(podLogLevel); + }); + } - if (!this.options.specsRepo) { - auto.logger.log.info(logMessage(`Pushing to Cocoapods trunk`)); - await execPromise(pod, [ - ...commands, - "trunk", - "push", - ...(this.options.flags || []), - this.options.podspecPath, - ...podLogLevel, - ]); - return; - } - - try { - const existingRepos = await execPromise(pod, [ - ...commands, - "repo", - "list", - ]); - if (existingRepos.indexOf("autoPublishRepo") !== -1) { - auto.logger.log.info("Removing existing autoPublishRepo"); - await execPromise(pod, [ - ...commands, - "repo", - "remove", - "autoPublishRepo", - ...podLogLevel, - ]); - } - } catch (error) { - auto.logger.log.warn( - `Error Checking for existing Specs repositories: ${error}` - ); - } - - try { - await execPromise(pod, [ - ...commands, - "repo", - "add", - "autoPublishRepo", - this.options.specsRepo, - ...podLogLevel, - ]); - - auto.logger.log.info( - logMessage(`Pushing to specs repo: ${this.options.specsRepo}`) - ); + /** + * + */ + async publishPodSpec(podLogLevel: string[]) { + const [pod, ...commands] = this.options.podCommand?.split(" ") || ["pod"]; + if (!this.options.specsRepo) { + this.logger?.log.info(logMessage(`Pushing to Cocoapods trunk`)); + await execPromise(pod, [ + ...commands, + "trunk", + "push", + ...(this.options.flags || []), + this.options.podspecPath, + ...podLogLevel, + ]); + return; + } - await execPromise(pod, [ - ...commands, - "repo", - "push", - ...(this.options.flags || []), - "autoPublishRepo", - this.options.podspecPath, - ...podLogLevel, - ]); - } catch (error) { - auto.logger.log.error( - logMessage( - `Error pushing to specs repo: ${this.options.specsRepo}. Error: ${error}` - ) - ); - process.exit(1); - } finally { + try { + const existingRepos = await execPromise(pod, [ + ...commands, + "repo", + "list", + ]); + if (existingRepos.indexOf("autoPublishRepo") !== -1) { + this.logger?.log.info("Removing existing autoPublishRepo"); await execPromise(pod, [ ...commands, "repo", @@ -274,6 +339,50 @@ export default class CocoapodsPlugin implements IPlugin { ...podLogLevel, ]); } - }); + } catch (error) { + this.logger?.log.warn( + `Error Checking for existing Specs repositories: ${error}` + ); + } + + try { + await execPromise(pod, [ + ...commands, + "repo", + "add", + "autoPublishRepo", + this.options.specsRepo, + ...podLogLevel, + ]); + + this.logger?.log.info( + logMessage(`Pushing to specs repo: ${this.options.specsRepo}`) + ); + + await execPromise(pod, [ + ...commands, + "repo", + "push", + ...(this.options.flags || []), + "autoPublishRepo", + this.options.podspecPath, + ...podLogLevel, + ]); + } catch (error) { + this.logger?.log.error( + logMessage( + `Error pushing to specs repo: ${this.options.specsRepo}. Error: ${error}` + ) + ); + process.exit(1); + } finally { + await execPromise(pod, [ + ...commands, + "repo", + "remove", + "autoPublishRepo", + ...podLogLevel, + ]); + } } }