diff --git a/Dockerfile b/Dockerfile index ba90e46f8ddb1f923faf45e330d60c141aa13baa..c8da0e0027ce2cae2351b234b27d9b2ed15f49f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -161,6 +161,12 @@ RUN rm -rf /usr/bin/python && ln /usr/bin/python3.8 /usr/bin/python RUN curl --silent https://bootstrap.pypa.io/get-pip.py | python +# CocoaPods +RUN apt-get update && apt-get install -y ruby ruby2.5-dev && rm -rf /var/lib/apt/lists/* +RUN ruby --version +ENV COCOAPODS_VERSION 1.9.0 +RUN gem install --no-rdoc --no-ri cocoapods -v ${COCOAPODS_VERSION} + # Set up ubuntu user and home directory with access to users in the root group (0) ENV HOME=/home/ubuntu diff --git a/lib/datasource/pod/index.spec.ts b/lib/datasource/pod/index.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..a611fe2f3e8f748554d419c92b1157d64e6eab1d --- /dev/null +++ b/lib/datasource/pod/index.spec.ts @@ -0,0 +1,133 @@ +import { api as _api } from '../../platform/github/gh-got-wrapper'; +import { getPkgReleases } from '.'; +import { mocked } from '../../../test/util'; +import { GotResponse } from '../../platform'; +import { GetReleasesConfig } from '../common'; + +const api = mocked(_api); + +jest.mock('../../platform/github/gh-got-wrapper'); + +const config = { + lookupName: 'foo', + registryUrls: ['https://github.com/CocoaPods/Specs'], +}; + +describe('datasource/cocoapods', () => { + describe('getPkgReleases', () => { + beforeEach(() => global.renovateCache.rmAll()); + it('returns null for invalid inputs', async () => { + api.get.mockResolvedValueOnce(null); + expect( + await getPkgReleases({ registryUrls: [] } as GetReleasesConfig) + ).toBeNull(); + expect( + await getPkgReleases({ + lookupName: null, + }) + ).toBeNull(); + expect( + await getPkgReleases({ + lookupName: 'foobar', + registryUrls: [], + }) + ).toBeNull(); + }); + it('returns null for empty result', async () => { + api.get.mockResolvedValueOnce(null); + expect(await getPkgReleases(config)).toBeNull(); + }); + it('returns null for missing fields', async () => { + api.get.mockResolvedValueOnce({} as GotResponse); + expect(await getPkgReleases(config)).toBeNull(); + + api.get.mockResolvedValueOnce({ body: '' } as GotResponse); + expect(await getPkgReleases(config)).toBeNull(); + }); + it('returns null for 404', async () => { + api.get.mockImplementation(() => + Promise.reject({ + statusCode: 404, + }) + ); + expect( + await getPkgReleases({ + ...config, + registryUrls: [ + ...config.registryUrls, + 'invalid', + 'https://github.com/foo/bar', + ], + }) + ).toBeNull(); + }); + it('returns null for 401', async () => { + api.get.mockImplementationOnce(() => + Promise.reject({ + statusCode: 401, + }) + ); + expect(await getPkgReleases(config)).toBeNull(); + }); + it('throws for 429', async () => { + api.get.mockImplementationOnce(() => + Promise.reject({ + statusCode: 429, + }) + ); + await expect(getPkgReleases(config)).rejects.toThrowError( + 'registry-failure' + ); + }); + it('throws for 5xx', async () => { + api.get.mockImplementationOnce(() => + Promise.reject({ + statusCode: 502, + }) + ); + await expect(getPkgReleases(config)).rejects.toThrowError( + 'registry-failure' + ); + }); + it('returns null for unknown error', async () => { + api.get.mockImplementationOnce(() => { + throw new Error(); + }); + expect(await getPkgReleases(config)).toBeNull(); + }); + it('processes real data from CDN', async () => { + api.get.mockResolvedValueOnce({ + body: 'foo/1.2.3', + } as GotResponse); + expect( + await getPkgReleases({ + ...config, + registryUrls: ['https://cdn.cocoapods.org'], + }) + ).toEqual({ + releases: [ + { + version: '1.2.3', + }, + ], + }); + }); + it('processes real data from Github', async () => { + api.get.mockResolvedValueOnce({ + body: [{ name: '1.2.3' }], + } as GotResponse); + expect( + await getPkgReleases({ + ...config, + registryUrls: ['https://github.com/Artsy/Specs'], + }) + ).toEqual({ + releases: [ + { + version: '1.2.3', + }, + ], + }); + }); + }); +}); diff --git a/lib/datasource/pod/index.ts b/lib/datasource/pod/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d0cb4541b6a3ac7697469d266db9b4ff7f0ea394 --- /dev/null +++ b/lib/datasource/pod/index.ts @@ -0,0 +1,170 @@ +import crypto from 'crypto'; +import { api } from '../../platform/github/gh-got-wrapper'; +import { GetReleasesConfig, ReleaseResult } from '../common'; +import { logger } from '../../logger'; + +export const id = 'pod'; + +const cacheNamespace = `datasource-${id}`; +const cacheMinutes = 30; + +function shardParts(lookupName: string): string[] { + return crypto + .createHash('md5') + .update(lookupName) + .digest('hex') + .slice(0, 3) + .split(''); +} + +function releasesGithubUrl( + lookupName: string, + opts: { account: string; repo: string; useShard: boolean } +): string { + const { useShard, account, repo } = opts; + const prefix = 'https://api.github.com/repos'; + const shard = shardParts(lookupName).join('/'); + const suffix = useShard ? `${shard}/${lookupName}` : lookupName; + return `${prefix}/${account}/${repo}/contents/Specs/${suffix}`; +} + +async function makeRequest<T = unknown>( + url: string, + lookupName: string, + json = true +): Promise<T | null> { + try { + const resp = await api.get(url, { json }); + if (resp && resp.body) { + return resp.body; + } + } catch (err) { + const errorData = { lookupName, err }; + + if ( + err.statusCode === 429 || + (err.statusCode >= 500 && err.statusCode < 600) + ) { + logger.warn({ lookupName, err }, `CocoaPods registry failure`); + throw new Error('registry-failure'); + } + + if (err.statusCode === 401) { + logger.debug(errorData, 'Authorization error'); + } else if (err.statusCode === 404) { + logger.debug(errorData, 'Package lookup error'); + } else { + logger.warn(errorData, 'CocoaPods lookup failure: Unknown error'); + } + } + + return null; +} + +const githubRegex = /^https:\/\/github\.com\/(?<account>[^/]+)\/(?<repo>[^/]+?)(\.git|\/.*)?$/; + +async function getReleasesFromGithub( + lookupName: string, + registryUrl: string, + useShard = false +): Promise<ReleaseResult | null> { + const match = githubRegex.exec(registryUrl); + const { account, repo } = (match && match.groups) || {}; + const opts = { account, repo, useShard }; + const url = releasesGithubUrl(lookupName, opts); + const resp = await makeRequest<{ name: string }[]>(url, lookupName); + if (resp) { + const releases = resp.map(({ name }) => ({ version: name })); + return { releases }; + } + + if (!useShard) { + return getReleasesFromGithub(lookupName, registryUrl, true); + } + + return null; +} + +function releasesCDNUrl(lookupName: string, registryUrl: string): string { + const shard = shardParts(lookupName).join('_'); + return `${registryUrl}/all_pods_versions_${shard}.txt`; +} + +async function getReleasesFromCDN( + lookupName: string, + registryUrl: string +): Promise<ReleaseResult | null> { + const url = releasesCDNUrl(lookupName, registryUrl); + const resp = await makeRequest<string>(url, lookupName, false); + if (resp) { + const lines = resp.split('\n'); + for (let idx = 0; idx < lines.length; idx += 1) { + const line = lines[idx]; + const [name, ...versions] = line.split('/'); + if (name === lookupName.replace(/\/.*$/, '')) { + const releases = versions.map(version => ({ version })); + return { releases }; + } + } + } + return null; +} + +const defaultCDN = 'https://cdn.cocoapods.org'; + +function isDefaultRepo(url: string): boolean { + const match = githubRegex.exec(url); + if (match) { + const { account, repo } = match.groups || {}; + return ( + account.toLowerCase() === 'cocoapods' && repo.toLowerCase() === 'specs' + ); // https://github.com/CocoaPods/Specs.git + } + return false; +} + +export async function getPkgReleases( + config: GetReleasesConfig +): Promise<ReleaseResult | null> { + const { lookupName } = config; + let { registryUrls } = config; + registryUrls = + registryUrls && registryUrls.length ? registryUrls : [defaultCDN]; + + if (!lookupName) { + logger.debug(config, `CocoaPods: invalid lookup name`); + return null; + } + + const podName = lookupName.replace(/\/.*$/, ''); + + const cachedResult = await renovateCache.get<ReleaseResult>( + cacheNamespace, + podName + ); + /* istanbul ignore next line */ + if (cachedResult) { + logger.debug(`CocoaPods: Return cached result for ${podName}`); + return cachedResult; + } + + let result: ReleaseResult | null = null; + for (let idx = 0; !result && idx < registryUrls.length; idx += 1) { + let registryUrl = registryUrls[idx].replace(/\/+$/, ''); + + // In order to not abuse github API limits, query CDN instead + if (isDefaultRepo(registryUrl)) registryUrl = defaultCDN; + + if (githubRegex.exec(registryUrl)) { + result = await getReleasesFromGithub(podName, registryUrl); + } else { + result = await getReleasesFromCDN(podName, registryUrl); + } + } + + if (result) { + await renovateCache.set(cacheNamespace, podName, result, cacheMinutes); + } + + return result; +} diff --git a/lib/manager/cocoapods/__fixtures__/Podfile.complex b/lib/manager/cocoapods/__fixtures__/Podfile.complex new file mode 100644 index 0000000000000000000000000000000000000000..be309bfcd4b48e2585b097cf6f8270c51da728e9 --- /dev/null +++ b/lib/manager/cocoapods/__fixtures__/Podfile.complex @@ -0,0 +1,72 @@ +platform :ios, '9.0' +# use_frameworks! +inhibit_all_warnings! + +source "https://github.com/CocoaPods/Specs.git" + +target 'Sample' do + + pod 'IQKeyboardManager', '~> 6.5.0' + pod 'CYLTabBarController', '~> 1.28.3' + + pod 'PureLayout', '~> 3.1.4' + pod 'AFNetworking/Serialization', '~> 3.2.1' + pod 'AFNetworking/Security', '~> 3.2.1' + pod 'AFNetworking/Reachability', '~> 3.2.1' + pod 'AFNetworking/NSURLSession', '~> 3.2.1' + # pod 'SVProgressHUD', '~> 2.2.5' + pod 'MBProgressHUD', '~> 1.1.0' + pod 'MJRefresh', '~> 3.1.16' + pod 'MJExtension', '~> 3.1.0' + pod 'TYPagerController', '~> 2.1.2' + pod 'YYImage', '~> 1.0.4' + pod 'SDWebImage', '~> 5.0' + pod 'SDCycleScrollView','~> 1.80' + pod 'NullSafe', '~> 2.0' + # pod 'ZLPhotoBrowser' + pod 'TZImagePickerController', '~> 3.2.1' + pod 'TOCropViewController', '~> 2.5.1' + # pod 'RSKImageCropper', '~> 2.2.3' + # pod 'LBPhotoBrowser', '~> 2.2.2' + # pod 'YBImageBrowser', '~> 3.0.3' + pod 'FMDB', '~> 2.7.5' + pod 'FDStackView', '~> 1.0.1' + pod 'LYEmptyView' + + pod 'MMKV', '~> 1.0.22' + + pod 'fishhook' + + pod 'CocoaLumberjack', '~> 3.5.3' + + pod 'GZIP', '~> 1.2' + + pod 'LBXScan/LBXNative','~> 2.3' + pod 'LBXScan/LBXZXing','~> 2.3' + # pod 'LBXScan/LBXZBar','~> 2.3' + pod 'LBXScan/UI','~> 2.3' + + pod 'MLeaksFinder' + pod 'FBMemoryProfiler' + + target 'SampleTests' do + inherit! :search_paths + # Pods for testing + end + + target 'SampleUITests' do + inherit! :search_paths + # Pods for testing + end + +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f <= 8.0 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '9.0' + end + end + end +end diff --git a/lib/manager/cocoapods/__fixtures__/Podfile.simple b/lib/manager/cocoapods/__fixtures__/Podfile.simple new file mode 100644 index 0000000000000000000000000000000000000000..1c8ffcff3fc15f1d61eea569a4c6a27b8070acda --- /dev/null +++ b/lib/manager/cocoapods/__fixtures__/Podfile.simple @@ -0,0 +1,11 @@ +source 'https://github.com/Artsy/Specs.git' + +pod 'a' +pod 'a/sub' +pod 'b', '1.2.3' +pod 'c', "1.2.3" +pod 'd', :path => '~/Documents/Alamofire' +pod 'e', :git => 'e.git' +pod 'f', :git => 'f.git', :branch => 'dev' +pod 'g', :git => 'g.git', :tag => '3.2.1' +pod 'h', :git => 'https://github.com/foo/bar.git', :tag => '0.0.1' diff --git a/lib/manager/cocoapods/__snapshots__/artifacts.spec.ts.snap b/lib/manager/cocoapods/__snapshots__/artifacts.spec.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..c6635e937ae61dfaf44a42be5592314c0009bbd6 --- /dev/null +++ b/lib/manager/cocoapods/__snapshots__/artifacts.spec.ts.snap @@ -0,0 +1,167 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`.updateArtifacts() catches write error 1`] = ` +Array [ + Object { + "artifactError": Object { + "lockFile": "Podfile.lock", + "stderr": "not found", + }, + }, +] +`; + +exports[`.updateArtifacts() catches write error 2`] = `Array []`; + +exports[`.updateArtifacts() dynamically selects Docker image tag 1`] = ` +Array [ + Object { + "cmd": "docker pull renovate/cocoapods:1.2.4", + "options": Object { + "encoding": "utf-8", + }, + }, + Object { + "cmd": "docker run --rm --user=ubuntu -v \\"/tmp/github/some/repo\\":\\"/tmp/github/some/repo\\" -w \\"/tmp/github/some/repo\\" renovate/cocoapods:1.2.4 bash -l -c \\"pod install\\"", + "options": Object { + "cwd": "/tmp/github/some/repo", + "encoding": "utf-8", + "env": Object { + "HOME": "/home/user", + "HTTPS_PROXY": "https://example.com", + "HTTP_PROXY": "http://example.com", + "LANG": "en_US.UTF-8", + "LC_ALL": "en_US", + "NO_PROXY": "localhost", + "PATH": "/tmp/path", + }, + }, + }, +] +`; + +exports[`.updateArtifacts() falls back to the \`latest\` Docker image tag 1`] = ` +Array [ + Object { + "cmd": "docker pull renovate/cocoapods:latest", + "options": Object { + "encoding": "utf-8", + }, + }, + Object { + "cmd": "docker run --rm --user=ubuntu -v \\"/tmp/github/some/repo\\":\\"/tmp/github/some/repo\\" -w \\"/tmp/github/some/repo\\" renovate/cocoapods:latest bash -l -c \\"pod install\\"", + "options": Object { + "cwd": "/tmp/github/some/repo", + "encoding": "utf-8", + "env": Object { + "HOME": "/home/user", + "HTTPS_PROXY": "https://example.com", + "HTTP_PROXY": "http://example.com", + "LANG": "en_US.UTF-8", + "LC_ALL": "en_US", + "NO_PROXY": "localhost", + "PATH": "/tmp/path", + }, + }, + }, +] +`; + +exports[`.updateArtifacts() returns null for invalid local directory 1`] = `Array []`; + +exports[`.updateArtifacts() returns null if no Podfile.lock found 1`] = `Array []`; + +exports[`.updateArtifacts() returns null if no updatedDeps were provided 1`] = `Array []`; + +exports[`.updateArtifacts() returns null if unchanged 1`] = ` +Array [ + Object { + "cmd": "pod install", + "options": Object { + "cwd": "/tmp/github/some/repo", + "encoding": "utf-8", + "env": Object { + "HOME": "/home/user", + "HTTPS_PROXY": "https://example.com", + "HTTP_PROXY": "http://example.com", + "LANG": "en_US.UTF-8", + "LC_ALL": "en_US", + "NO_PROXY": "localhost", + "PATH": "/tmp/path", + }, + }, + }, +] +`; + +exports[`.updateArtifacts() returns null if updatedDeps is empty 1`] = `Array []`; + +exports[`.updateArtifacts() returns pod exec error 1`] = ` +Array [ + Object { + "artifactError": Object { + "lockFile": "Podfile.lock", + "stderr": "exec exception", + }, + }, +] +`; + +exports[`.updateArtifacts() returns pod exec error 2`] = ` +Array [ + Object { + "cmd": "pod install", + "options": Object { + "cwd": "/tmp/github/some/repo", + "encoding": "utf-8", + "env": Object { + "HOME": "/home/user", + "HTTPS_PROXY": "https://example.com", + "HTTP_PROXY": "http://example.com", + "LANG": "en_US.UTF-8", + "LC_ALL": "en_US", + "NO_PROXY": "localhost", + "PATH": "/tmp/path", + }, + }, + }, +] +`; + +exports[`.updateArtifacts() returns updated Podfile 1`] = ` +Array [ + Object { + "file": Object { + "contents": "New Podfile", + "name": "Podfile.lock", + }, + }, +] +`; + +exports[`.updateArtifacts() returns updated Podfile 2`] = ` +Array [ + Object { + "cmd": "docker pull renovate/cocoapods", + "options": Object { + "encoding": "utf-8", + }, + }, + Object { + "cmd": "docker run --rm -v \\"/tmp/github/some/repo\\":\\"/tmp/github/some/repo\\" -w \\"/tmp/github/some/repo\\" renovate/cocoapods bash -l -c \\"pod install\\"", + "options": Object { + "cwd": "/tmp/github/some/repo", + "encoding": "utf-8", + "env": Object { + "HOME": "/home/user", + "HTTPS_PROXY": "https://example.com", + "HTTP_PROXY": "http://example.com", + "LANG": "en_US.UTF-8", + "LC_ALL": "en_US", + "NO_PROXY": "localhost", + "PATH": "/tmp/path", + }, + }, + }, +] +`; diff --git a/lib/manager/cocoapods/__snapshots__/extract.spec.ts.snap b/lib/manager/cocoapods/__snapshots__/extract.spec.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..003d829b8cc16199f5a9aaa71132645373560f29 --- /dev/null +++ b/lib/manager/cocoapods/__snapshots__/extract.spec.ts.snap @@ -0,0 +1,394 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`lib/manager/cocoapods/extract extractPackageFile() extracts all dependencies 1`] = ` +Array [ + Object { + "depName": "a", + "groupName": "a", + "skipReason": "unknown-version", + }, + Object { + "depName": "a/sub", + "groupName": "a", + "skipReason": "unknown-version", + }, + Object { + "currentValue": "1.2.3", + "datasource": "pod", + "depName": "b", + "groupName": "b", + "managerData": Object { + "lineNumber": 4, + }, + "registryUrls": Array [ + "https://github.com/Artsy/Specs.git", + ], + }, + Object { + "currentValue": "1.2.3", + "datasource": "pod", + "depName": "c", + "groupName": "c", + "managerData": Object { + "lineNumber": 5, + }, + "registryUrls": Array [ + "https://github.com/Artsy/Specs.git", + ], + }, + Object { + "depName": "d", + "groupName": "d", + "skipReason": "path-dependency", + }, + Object { + "depName": "e", + "groupName": "e", + "skipReason": "git-dependency", + }, + Object { + "depName": "f", + "groupName": "f", + "skipReason": "git-dependency", + }, + Object { + "managerData": Object { + "lineNumber": 9, + }, + }, + Object { + "currentValue": "0.0.1", + "datasource": "github-tags", + "depName": "h", + "lookupName": "foo/bar", + "managerData": Object { + "lineNumber": 10, + }, + }, +] +`; + +exports[`lib/manager/cocoapods/extract extractPackageFile() extracts all dependencies 2`] = ` +Array [ + Object { + "currentValue": "~> 6.5.0", + "datasource": "pod", + "depName": "IQKeyboardManager", + "groupName": "IQKeyboardManager", + "managerData": Object { + "lineNumber": 8, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "currentValue": "~> 1.28.3", + "datasource": "pod", + "depName": "CYLTabBarController", + "groupName": "CYLTabBarController", + "managerData": Object { + "lineNumber": 9, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "currentValue": "~> 3.1.4", + "datasource": "pod", + "depName": "PureLayout", + "groupName": "PureLayout", + "managerData": Object { + "lineNumber": 11, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "currentValue": "~> 3.2.1", + "datasource": "pod", + "depName": "AFNetworking/Serialization", + "groupName": "AFNetworking", + "managerData": Object { + "lineNumber": 12, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "currentValue": "~> 3.2.1", + "datasource": "pod", + "depName": "AFNetworking/Security", + "groupName": "AFNetworking", + "managerData": Object { + "lineNumber": 13, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "currentValue": "~> 3.2.1", + "datasource": "pod", + "depName": "AFNetworking/Reachability", + "groupName": "AFNetworking", + "managerData": Object { + "lineNumber": 14, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "currentValue": "~> 3.2.1", + "datasource": "pod", + "depName": "AFNetworking/NSURLSession", + "groupName": "AFNetworking", + "managerData": Object { + "lineNumber": 15, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "currentValue": "~> 1.1.0", + "datasource": "pod", + "depName": "MBProgressHUD", + "groupName": "MBProgressHUD", + "managerData": Object { + "lineNumber": 17, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "currentValue": "~> 3.1.16", + "datasource": "pod", + "depName": "MJRefresh", + "groupName": "MJRefresh", + "managerData": Object { + "lineNumber": 18, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "currentValue": "~> 3.1.0", + "datasource": "pod", + "depName": "MJExtension", + "groupName": "MJExtension", + "managerData": Object { + "lineNumber": 19, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "currentValue": "~> 2.1.2", + "datasource": "pod", + "depName": "TYPagerController", + "groupName": "TYPagerController", + "managerData": Object { + "lineNumber": 20, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "currentValue": "~> 1.0.4", + "datasource": "pod", + "depName": "YYImage", + "groupName": "YYImage", + "managerData": Object { + "lineNumber": 21, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "currentValue": "~> 5.0", + "datasource": "pod", + "depName": "SDWebImage", + "groupName": "SDWebImage", + "managerData": Object { + "lineNumber": 22, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "currentValue": "~> 1.80", + "datasource": "pod", + "depName": "SDCycleScrollView", + "groupName": "SDCycleScrollView", + "managerData": Object { + "lineNumber": 23, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "currentValue": "~> 2.0", + "datasource": "pod", + "depName": "NullSafe", + "groupName": "NullSafe", + "managerData": Object { + "lineNumber": 24, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "currentValue": "~> 3.2.1", + "datasource": "pod", + "depName": "TZImagePickerController", + "groupName": "TZImagePickerController", + "managerData": Object { + "lineNumber": 26, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "currentValue": "~> 2.5.1", + "datasource": "pod", + "depName": "TOCropViewController", + "groupName": "TOCropViewController", + "managerData": Object { + "lineNumber": 27, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "currentValue": "~> 2.7.5", + "datasource": "pod", + "depName": "FMDB", + "groupName": "FMDB", + "managerData": Object { + "lineNumber": 31, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "currentValue": "~> 1.0.1", + "datasource": "pod", + "depName": "FDStackView", + "groupName": "FDStackView", + "managerData": Object { + "lineNumber": 32, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "depName": "LYEmptyView", + "groupName": "LYEmptyView", + "skipReason": "unknown-version", + }, + Object { + "currentValue": "~> 1.0.22", + "datasource": "pod", + "depName": "MMKV", + "groupName": "MMKV", + "managerData": Object { + "lineNumber": 35, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "depName": "fishhook", + "groupName": "fishhook", + "skipReason": "unknown-version", + }, + Object { + "currentValue": "~> 3.5.3", + "datasource": "pod", + "depName": "CocoaLumberjack", + "groupName": "CocoaLumberjack", + "managerData": Object { + "lineNumber": 39, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "currentValue": "~> 1.2", + "datasource": "pod", + "depName": "GZIP", + "groupName": "GZIP", + "managerData": Object { + "lineNumber": 41, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "currentValue": "~> 2.3", + "datasource": "pod", + "depName": "LBXScan/LBXNative", + "groupName": "LBXScan", + "managerData": Object { + "lineNumber": 43, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "currentValue": "~> 2.3", + "datasource": "pod", + "depName": "LBXScan/LBXZXing", + "groupName": "LBXScan", + "managerData": Object { + "lineNumber": 44, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "currentValue": "~> 2.3", + "datasource": "pod", + "depName": "LBXScan/UI", + "groupName": "LBXScan", + "managerData": Object { + "lineNumber": 46, + }, + "registryUrls": Array [ + "https://github.com/CocoaPods/Specs.git", + ], + }, + Object { + "depName": "MLeaksFinder", + "groupName": "MLeaksFinder", + "skipReason": "unknown-version", + }, + Object { + "depName": "FBMemoryProfiler", + "groupName": "FBMemoryProfiler", + "skipReason": "unknown-version", + }, +] +`; diff --git a/lib/manager/cocoapods/artifacts.spec.ts b/lib/manager/cocoapods/artifacts.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..13726577b00cb9f0d6ed34a33c0700a0bd76b814 --- /dev/null +++ b/lib/manager/cocoapods/artifacts.spec.ts @@ -0,0 +1,217 @@ +import { join } from 'upath'; +import _fs from 'fs-extra'; +import { exec as _exec } from 'child_process'; +import Git from 'simple-git/promise'; +import { platform as _platform } from '../../platform'; +import { updateArtifacts } from '.'; +import * as _datasource from '../../datasource/docker'; +import { mocked } from '../../../test/util'; +import { envMock, mockExecAll } from '../../../test/execUtil'; +import * as _env from '../../util/exec/env'; +import { setExecConfig } from '../../util/exec'; +import { BinarySource } from '../../util/exec/common'; + +jest.mock('fs-extra'); +jest.mock('child_process'); +jest.mock('../../util/exec/env'); +jest.mock('../../platform'); +jest.mock('../../datasource/docker'); + +const fs: jest.Mocked<typeof _fs> = _fs as any; +const exec: jest.Mock<typeof _exec> = _exec as any; +const env = mocked(_env); +const platform = mocked(_platform); +const datasource = mocked(_datasource); + +const config = { + localDir: join('/tmp/github/some/repo'), +}; + +describe('.updateArtifacts()', () => { + beforeEach(() => { + jest.resetAllMocks(); + env.getChildProcessEnv.mockReturnValue(envMock.basic); + setExecConfig(config); + + datasource.getPkgReleases.mockResolvedValue({ + releases: [ + { version: '1.2.0' }, + { version: '1.2.1' }, + { version: '1.2.2' }, + { version: '1.2.3' }, + { version: '1.2.4' }, + { version: '1.2.5' }, + ], + }); + }); + it('returns null if no Podfile.lock found', async () => { + const execSnapshots = mockExecAll(exec); + expect( + await updateArtifacts({ + packageFileName: 'Podfile', + updatedDeps: ['foo'], + newPackageFileContent: '', + config, + }) + ).toBeNull(); + expect(execSnapshots).toMatchSnapshot(); + }); + it('returns null if no updatedDeps were provided', async () => { + const execSnapshots = mockExecAll(exec); + expect( + await updateArtifacts({ + packageFileName: 'Podfile', + updatedDeps: [], + newPackageFileContent: '', + config, + }) + ).toBeNull(); + expect(execSnapshots).toMatchSnapshot(); + }); + it('returns null for invalid local directory', async () => { + const execSnapshots = mockExecAll(exec); + const noLocalDirConfig = { + localDir: undefined, + }; + expect( + await updateArtifacts({ + packageFileName: 'Podfile', + updatedDeps: ['foo'], + newPackageFileContent: '', + config: noLocalDirConfig, + }) + ).toBeNull(); + expect(execSnapshots).toMatchSnapshot(); + }); + it('returns null if updatedDeps is empty', async () => { + const execSnapshots = mockExecAll(exec); + expect( + await updateArtifacts({ + packageFileName: 'Podfile', + updatedDeps: [], + newPackageFileContent: '', + config, + }) + ).toBeNull(); + expect(execSnapshots).toMatchSnapshot(); + }); + it('returns null if unchanged', async () => { + const execSnapshots = mockExecAll(exec); + platform.getFile.mockResolvedValueOnce('Current Podfile'); + platform.getRepoStatus.mockResolvedValueOnce({ + modified: [], + } as Git.StatusResult); + fs.readFile.mockResolvedValueOnce('Current Podfile' as any); + expect( + await updateArtifacts({ + packageFileName: 'Podfile', + updatedDeps: ['foo'], + newPackageFileContent: '', + config, + }) + ).toBeNull(); + expect(execSnapshots).toMatchSnapshot(); + }); + it('returns updated Podfile', async () => { + const execSnapshots = mockExecAll(exec); + setExecConfig({ ...config, binarySource: BinarySource.Docker }); + platform.getFile.mockResolvedValueOnce('Old Podfile'); + platform.getRepoStatus.mockResolvedValueOnce({ + modified: ['Podfile.lock'], + } as Git.StatusResult); + fs.readFile.mockResolvedValueOnce('New Podfile' as any); + expect( + await updateArtifacts({ + packageFileName: 'Podfile', + updatedDeps: ['foo'], + newPackageFileContent: '', + config, + }) + ).toMatchSnapshot(); + expect(execSnapshots).toMatchSnapshot(); + }); + it('catches write error', async () => { + const execSnapshots = mockExecAll(exec); + platform.getFile.mockResolvedValueOnce('Current Podfile'); + fs.outputFile.mockImplementationOnce(() => { + throw new Error('not found'); + }); + expect( + await updateArtifacts({ + packageFileName: 'Podfile', + updatedDeps: ['foo'], + newPackageFileContent: '', + config, + }) + ).toMatchSnapshot(); + expect(execSnapshots).toMatchSnapshot(); + }); + it('returns pod exec error', async () => { + const execSnapshots = mockExecAll(exec, new Error('exec exception')); + platform.getFile.mockResolvedValueOnce('Old Podfile.lock'); + fs.outputFile.mockResolvedValueOnce(null as never); + fs.readFile.mockResolvedValueOnce('Old Podfile.lock' as any); + expect( + await updateArtifacts({ + packageFileName: 'Podfile', + updatedDeps: ['foo'], + newPackageFileContent: '', + config, + }) + ).toMatchSnapshot(); + expect(execSnapshots).toMatchSnapshot(); + }); + it('dynamically selects Docker image tag', async () => { + const execSnapshots = mockExecAll(exec); + + setExecConfig({ + ...config, + binarySource: 'docker', + dockerUser: 'ubuntu', + }); + + platform.getFile.mockResolvedValueOnce('COCOAPODS: 1.2.4'); + + fs.readFile.mockResolvedValueOnce('New Podfile' as any); + + platform.getRepoStatus.mockResolvedValueOnce({ + modified: ['Podfile.lock'], + } as Git.StatusResult); + + await updateArtifacts({ + packageFileName: 'Podfile', + updatedDeps: ['foo'], + newPackageFileContent: '', + config, + }); + expect(execSnapshots).toMatchSnapshot(); + }); + it('falls back to the `latest` Docker image tag', async () => { + const execSnapshots = mockExecAll(exec); + + setExecConfig({ + ...config, + binarySource: 'docker', + dockerUser: 'ubuntu', + }); + + platform.getFile.mockResolvedValueOnce('COCOAPODS: 1.2.4'); + datasource.getPkgReleases.mockResolvedValueOnce({ + releases: [], + }); + + fs.readFile.mockResolvedValueOnce('New Podfile' as any); + + platform.getRepoStatus.mockResolvedValueOnce({ + modified: ['Podfile.lock'], + } as Git.StatusResult); + + await updateArtifacts({ + packageFileName: 'Podfile', + updatedDeps: ['foo'], + newPackageFileContent: '', + config, + }); + expect(execSnapshots).toMatchSnapshot(); + }); +}); diff --git a/lib/manager/cocoapods/artifacts.ts b/lib/manager/cocoapods/artifacts.ts new file mode 100644 index 0000000000000000000000000000000000000000..cfab5e393751f362ec2a8fd83c367bca01363e0f --- /dev/null +++ b/lib/manager/cocoapods/artifacts.ts @@ -0,0 +1,89 @@ +import { platform } from '../../platform'; +import { exec, ExecOptions } from '../../util/exec'; +import { logger } from '../../logger'; +import { UpdateArtifact, UpdateArtifactsResult } from '../common'; +import { + getSiblingFileName, + readLocalFile, + writeLocalFile, +} from '../../util/fs'; + +export async function updateArtifacts({ + packageFileName, + updatedDeps, + newPackageFileContent, + config, +}: UpdateArtifact): Promise<UpdateArtifactsResult[] | null> { + logger.debug(`cocoapods.getArtifacts(${packageFileName})`); + + if (updatedDeps.length < 1) { + logger.debug('CocoaPods: empty update - returning null'); + return null; + } + + const lockFileName = getSiblingFileName(packageFileName, 'Podfile.lock'); + + try { + await writeLocalFile(packageFileName, newPackageFileContent); + } catch (err) { + logger.warn({ err }, 'Podfile could not be written'); + return [ + { + artifactError: { + lockFile: lockFileName, + stderr: err.message, + }, + }, + ]; + } + + const existingLockFileContent = await platform.getFile(lockFileName); + if (!existingLockFileContent) { + logger.debug(`Lockfile not found: ${lockFileName}`); + return null; + } + + const match = new RegExp(/^COCOAPODS: (?<cocoapodsVersion>.*)$/m).exec( + existingLockFileContent + ); + const tagConstraint = + match && match.groups ? match.groups.cocoapodsVersion : null; + + const cmd = 'pod install'; + const execOptions: ExecOptions = { + cwdFile: packageFileName, + docker: { + image: 'renovate/cocoapods', + tagScheme: 'ruby', + tagConstraint, + }, + }; + + try { + await exec(cmd, execOptions); + } catch (err) { + return [ + { + artifactError: { + lockFile: lockFileName, + stderr: err.stderr || err.stdout || err.message, + }, + }, + ]; + } + + const status = await platform.getRepoStatus(); + if (!status.modified.includes(lockFileName)) { + return null; + } + logger.debug('Returning updated Gemfile.lock'); + const lockFileContent = await readLocalFile(lockFileName); + return [ + { + file: { + name: lockFileName, + contents: lockFileContent, + }, + }, + ]; +} diff --git a/lib/manager/cocoapods/extract.spec.ts b/lib/manager/cocoapods/extract.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd7c2b3005cd154698f83f24c5f0fb9c18c65a76 --- /dev/null +++ b/lib/manager/cocoapods/extract.spec.ts @@ -0,0 +1,25 @@ +import fs from 'fs-extra'; +import path from 'path'; +import { extractPackageFile } from '.'; + +const simplePodfile = fs.readFileSync( + path.resolve(__dirname, './__fixtures__/Podfile.simple'), + 'utf-8' +); + +const complexPodfile = fs.readFileSync( + path.resolve(__dirname, './__fixtures__/Podfile.complex'), + 'utf-8' +); + +describe('lib/manager/cocoapods/extract', () => { + describe('extractPackageFile()', () => { + it('extracts all dependencies', () => { + const simpleResult = extractPackageFile(simplePodfile).deps; + expect(simpleResult).toMatchSnapshot(); + + const complexResult = extractPackageFile(complexPodfile).deps; + expect(complexResult).toMatchSnapshot(); + }); + }); +}); diff --git a/lib/manager/cocoapods/extract.ts b/lib/manager/cocoapods/extract.ts new file mode 100644 index 0000000000000000000000000000000000000000..0e66c59a53eddb6a8ab1fb2552b12722519eb2ae --- /dev/null +++ b/lib/manager/cocoapods/extract.ts @@ -0,0 +1,133 @@ +import { logger } from '../../logger'; +import { PackageDependency, PackageFile } from '../common'; +import * as datasourcePod from '../../datasource/pod'; + +const regexMappings = [ + /^\s*pod\s+(['"])(?<spec>[^'"/]+)(\/(?<subspec>[^'"]+))?\1/, + /^\s*pod\s+(['"])[^'"]+\1\s*,\s*(['"])(?<currentValue>[^'"]+)\2\s*$/, + /,\s*:git\s*=>\s*(['"])(?<git>[^'"]+)\1/, + /,\s*:tag\s*=>\s*(['"])(?<tag>[^'"]+)\1/, + /,\s*:path\s*=>\s*(['"])(?<path>[^'"]+)\1/, + /^\s*source\s*(['"])(?<source>[^'"]+)\1/, +]; + +export interface ParsedLine { + depName?: string; + groupName?: string; + spec?: string; + subspec?: string; + currentValue?: string; + git?: string; + tag?: string; + path?: string; + source?: string; +} + +export function parseLine(line: string): ParsedLine { + const result: ParsedLine = {}; + for (const regex of Object.values(regexMappings)) { + const match = regex.exec(line.replace(/#.*$/, '')); + if (match && match.groups) { + Object.assign(result, match.groups); + } + } + + if (result.spec) { + const depName = result.subspec + ? `${result.spec}/${result.subspec}` + : result.spec; + const groupName = result.spec; + if (depName) result.depName = depName; + if (groupName) result.groupName = groupName; + delete result.spec; + delete result.subspec; + } + + return result; +} + +export function gitDep(parsedLine: ParsedLine): PackageDependency | null { + const { depName, git, tag } = parsedLine; + if (git && git.startsWith('https://github.com/')) { + const githubMatch = /https:\/\/github\.com\/(?<account>[^/]+)\/(?<repo>[^/]+)/.exec( + git + ); + const { account, repo } = (githubMatch && githubMatch.groups) || {}; + if (account && repo) { + return { + datasource: 'github-tags', + depName, + lookupName: `${account}/${repo.replace(/\.git$/, '')}`, + currentValue: tag, + }; + } + } + + return null; // TODO: gitlab or gitTags datasources? +} + +export function extractPackageFile(content: string): PackageFile | null { + logger.trace('cocoapods.extractPackageFile()'); + const deps: PackageDependency[] = []; + const lines: string[] = content.split('\n'); + + const registryUrls: string[] = []; + + for (let lineNumber = 0; lineNumber < lines.length; lineNumber += 1) { + const line = lines[lineNumber]; + const parsedLine = parseLine(line); + const { + depName, + groupName, + currentValue, + git, + tag, + path, + source, + }: ParsedLine = parsedLine; + + if (source) { + registryUrls.push(source.replace(/\/*$/, '')); + } + + if (depName) { + const managerData = { lineNumber }; + let dep: PackageDependency = { + depName, + groupName, + skipReason: 'unknown-version', + }; + + if (currentValue) { + dep = { + depName, + groupName, + datasource: datasourcePod.id, + currentValue, + managerData, + registryUrls, + }; + } else if (git) { + if (tag) { + dep = { ...gitDep(parsedLine), managerData }; + } else { + dep = { + depName, + groupName, + skipReason: 'git-dependency', + }; + } + } else if (path) { + dep = { + depName, + groupName, + skipReason: 'path-dependency', + }; + } + + deps.push(dep); + } + } + + return deps.length ? { deps } : null; +} diff --git a/lib/manager/cocoapods/index.ts b/lib/manager/cocoapods/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f39f897123271ce71a99f0193adba91ec784bbb4 --- /dev/null +++ b/lib/manager/cocoapods/index.ts @@ -0,0 +1,11 @@ +import * as rubyVersioning from '../../versioning/ruby'; + +export { extractPackageFile } from './extract'; +export { updateDependency } from './update'; +export { updateArtifacts } from './artifacts'; + +export const defaultConfig = { + enabled: false, + fileMatch: ['(^|/)Podfile$'], + versioning: rubyVersioning.id, +}; diff --git a/lib/manager/cocoapods/readme.md b/lib/manager/cocoapods/readme.md new file mode 100644 index 0000000000000000000000000000000000000000..d57162968f606a90750b6863c17efe5146b9b70c --- /dev/null +++ b/lib/manager/cocoapods/readme.md @@ -0,0 +1,9 @@ +The `cocoapods` manager extracts dependencies with`datasource` type `pod`. It is currently in beta so disabled by default. To opt-in to the beta, add the following to your configuration: + +```json +{ + "cocoapods": { + "enabled": true + } +} +``` diff --git a/lib/manager/cocoapods/update.spec.ts b/lib/manager/cocoapods/update.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..ee73d947ceb93195fc5479a37acf8a8cb20c4baf --- /dev/null +++ b/lib/manager/cocoapods/update.spec.ts @@ -0,0 +1,45 @@ +import fs from 'fs-extra'; +import path from 'path'; +import { updateDependency } from '.'; + +const fileContent = fs.readFileSync( + path.resolve(__dirname, './__fixtures__/Podfile.simple'), + 'utf-8' +); + +describe('lib/manager/cocoapods/update', () => { + describe('updateDependency', () => { + it('replaces existing value', () => { + const upgrade = { + depName: 'b', + managerData: { lineNumber: 4 }, + currentValue: '1.2.3', + newValue: '2.0.0', + }; + const res = updateDependency({ fileContent, upgrade }); + expect(res).not.toEqual(fileContent); + expect(res.includes(upgrade.newValue)).toBe(true); + }); + it('returns same content', () => { + const upgrade = { + depName: 'b', + managerData: { lineNumber: 4 }, + currentValue: '1.2.3', + newValue: '1.2.3', + }; + const res = updateDependency({ fileContent, upgrade }); + expect(res).toEqual(fileContent); + expect(res).toBe(fileContent); + }); + it('returns null', () => { + const upgrade = { + depName: 'b', + managerData: { lineNumber: 0 }, + currentValue: '1.2.3', + newValue: '2.0.0', + }; + const res = updateDependency({ fileContent, upgrade }); + expect(res).toBeNull(); + }); + }); +}); diff --git a/lib/manager/cocoapods/update.ts b/lib/manager/cocoapods/update.ts new file mode 100644 index 0000000000000000000000000000000000000000..afb0b31da539b30433fab487d24032bfc3facb9f --- /dev/null +++ b/lib/manager/cocoapods/update.ts @@ -0,0 +1,39 @@ +import { logger } from '../../logger'; +import { UpdateDependencyConfig } from '../common'; +import { parseLine } from './extract'; + +function lineContainsDep(line: string, dep: string): boolean { + const { depName } = parseLine(line); + return dep === depName; +} + +export function updateDependency({ + fileContent, + upgrade, +}: UpdateDependencyConfig): string | null { + const { currentValue, managerData, depName, newValue } = upgrade; + + // istanbul ignore if + if (!currentValue || !managerData || !depName) { + logger.warn('Cocoapods: invalid upgrade object'); + return null; + } + + logger.debug(`cocoapods.updateDependency: ${newValue}`); + + const lines = fileContent.split('\n'); + const lineToChange = lines[managerData.lineNumber]; + + if (!lineContainsDep(lineToChange, depName)) return null; + + const regex = new RegExp(`(['"])${currentValue.replace('.', '\\.')}\\1`); + const newLine = lineToChange.replace(regex, `$1${newValue}$1`); + + if (newLine === lineToChange) { + logger.debug('No changes necessary'); + return fileContent; + } + + lines[managerData.lineNumber] = newLine; + return lines.join('\n'); +}