From 5db5708164591b5681ac2bca70c9adfaef81480b Mon Sep 17 00:00:00 2001 From: Konrad Pabjan Date: Thu, 9 Jul 2020 11:43:31 +0200 Subject: [PATCH] Support for multi path upload --- .github/workflows/test.yml | 30 ++++++++++++++ __tests__/search.test.ts | 66 +++++++++++++++++++++++++++++++ dist/index.js | 65 +++++++++++++++++++++++++++--- src/search.ts | 81 +++++++++++++++++++++++++++++++++++--- 4 files changed, 231 insertions(+), 11 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9a88e7c..2916259 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -75,6 +75,16 @@ jobs: name: 'GZip-Artifact' path: path/to/dir-3/ + # Upload a directory that contains a file that will be uploaded with GZip + - name: 'Upload artifact #4' + uses: ./ + with: + name: 'Multi-Path-Artifact' + path: | + path/to/dir-1/* + path/to/dir-[23]/* + !path/to/dir-3/*.txt + # Verify artifacts. Switch to download-artifact@v2 once it's out of preview # Download Artifact #1 and verify the correctness of the content @@ -138,3 +148,23 @@ jobs: Write-Error "File contents of downloaded artifact is incorrect" } shell: pwsh + + - name: 'Download artifact #4' + uses: actions/download-artifact@v1 + with: + name: 'Multi-Path-Artifact' + path: multi/artifact/path + + - name: 'Verify Artifact #4' + run: | + $file1 = "multi/artifact/path/to/dir-1/file1.txt" + $file2 = "multi/artifact/path/to/dir-2/file2.txt" + if(!(Test-Path -path $file1) -or !(Test-Path -path $file2)) + { + Write-Error "Expected files do not exist" + } + if(!((Get-Content $file1) -ceq "Lorem ipsum dolor sit amet") -or !((Get-Content $file2) -ceq "Hello world from file #2")) + { + Write-Error "File contents of downloaded artifacts are incorrect" + } + shell: pwsh diff --git a/__tests__/search.test.ts b/__tests__/search.test.ts index 4977811..feb2e13 100644 --- a/__tests__/search.test.ts +++ b/__tests__/search.test.ts @@ -286,4 +286,70 @@ describe('Search', () => { expect(searchResult.rootDirectory).toEqual(root) }) + + it('Multi path search - root directory', async () => { + const searchPath1 = path.join(root, 'folder-a') + const searchPath2 = path.join(root, 'folder-d') + + const searchPaths = searchPath1 + '\n' + searchPath2 + const searchResult = await findFilesToUpload(searchPaths) + + expect(searchResult.rootDirectory).toEqual(root) + expect(searchResult.filesToUpload.length).toEqual(7) + expect(searchResult.filesToUpload.includes(searchItem1Path)).toEqual(true) + expect(searchResult.filesToUpload.includes(searchItem2Path)).toEqual(true) + expect(searchResult.filesToUpload.includes(searchItem2Path)).toEqual(true) + expect(searchResult.filesToUpload.includes(searchItem4Path)).toEqual(true) + expect(searchResult.filesToUpload.includes(extraSearchItem1Path)).toEqual( + true + ) + expect(searchResult.filesToUpload.includes(extraSearchItem2Path)).toEqual( + true + ) + expect(searchResult.filesToUpload.includes(extraFileInFolderCPath)).toEqual( + true + ) + }) + + it('Multi path search - with exclude character', async () => { + const searchPath1 = path.join(root, 'folder-a') + const searchPath2 = path.join(root, 'folder-d') + const searchPath3 = path.join(root, 'folder-a', 'folder-b', '**/extra*.txt') + + // negating the third search path + const searchPaths = searchPath1 + '\n' + searchPath2 + '\n!' + searchPath3 + const searchResult = await findFilesToUpload(searchPaths) + + expect(searchResult.rootDirectory).toEqual(root) + expect(searchResult.filesToUpload.length).toEqual(5) + expect(searchResult.filesToUpload.includes(searchItem1Path)).toEqual(true) + expect(searchResult.filesToUpload.includes(searchItem2Path)).toEqual(true) + expect(searchResult.filesToUpload.includes(searchItem2Path)).toEqual(true) + expect(searchResult.filesToUpload.includes(searchItem4Path)).toEqual(true) + expect(searchResult.filesToUpload.includes(extraSearchItem2Path)).toEqual( + true + ) + }) + + it('Multi path search - non root directory', async () => { + const searchPath1 = path.join(root, 'folder-h', 'folder-i') + const searchPath2 = path.join(root, 'folder-h', 'folder-j', 'folder-k') + const searchPath3 = amazingFileInFolderHPath + + const searchPaths = searchPath1 + '\n' + searchPath2 + '\n' + searchPath3 + const searchResult = await findFilesToUpload(searchPaths) + + expect(searchResult.rootDirectory).toEqual(path.join(root, 'folder-h')) + expect(searchResult.filesToUpload.length).toEqual(4) + expect( + searchResult.filesToUpload.includes(amazingFileInFolderHPath) + ).toEqual(true) + expect(searchResult.filesToUpload.includes(extraSearchItem4Path)).toEqual( + true + ) + expect(searchResult.filesToUpload.includes(extraSearchItem5Path)).toEqual( + true + ) + expect(searchResult.filesToUpload.includes(lonelyFilePath)).toEqual(true) + }) }) diff --git a/dist/index.js b/dist/index.js index e234ece..2aa335b 100644 --- a/dist/index.js +++ b/dist/index.js @@ -6221,6 +6221,7 @@ var __importStar = (this && this.__importStar) || function (mod) { }; Object.defineProperty(exports, "__esModule", { value: true }); const glob = __importStar(__webpack_require__(281)); +const path = __importStar(__webpack_require__(622)); const core_1 = __webpack_require__(470); const fs_1 = __webpack_require__(747); const path_1 = __webpack_require__(622); @@ -6231,6 +6232,57 @@ function getDefaultGlobOptions() { omitBrokenSymbolicLinks: true }; } +/** + * If multiple paths are specific, the least common ancestor (LCA) of the search paths is used as + * the delimiter to control the directory structure for the artifact. This function returns the LCA + * when given an array of search paths + * + * Example 1: The patterns `/foo/` and `/bar/` returns `/` + * + * Example 2: The patterns `~/foo/bar/*` and `~/foo/voo/two/*` and `~/foo/mo/` returns `~/foo` + */ +function getMultiPathLCA(searchPaths) { + if (searchPaths.length < 2) { + throw new Error('At least two search paths must be provided'); + } + const commonPaths = new Array(); + const splitPaths = new Array(); + let smallestPathLength = Number.MAX_SAFE_INTEGER; + // split each of the search paths using the platform specific separator + for (const searchPath of searchPaths) { + core_1.debug(`Using search path ${searchPath}`); + const splitSearchPath = searchPath.split(path.sep); + // keep track of the smallest path length so that we don't accidentally later go out of bounds + smallestPathLength = Math.min(smallestPathLength, splitSearchPath.length); + splitPaths.push(splitSearchPath); + } + // on Unix-like file systems, the file separator exists at the beginning of the file path, make sure to preserve it + if (searchPaths[0].startsWith(path.sep)) { + commonPaths.push(path.sep); + } + let splitIndex = 0; + // function to check if the paths are the same at a specific index + function isPathTheSame() { + const common = splitPaths[0][splitIndex]; + for (let i = 1; i < splitPaths.length; i++) { + if (common !== splitPaths[i][splitIndex]) { + // a non-common index has been reached + return false; + } + } + // if all are the same, add to the end result & increment the index + commonPaths.push(common); + splitIndex++; + return true; + } + // Loop over all the search paths until there is a non-common ancestor or we go out of bounds + while (splitIndex < smallestPathLength) { + if (!isPathTheSame()) { + break; + } + } + return path.join(...commonPaths); +} function findFilesToUpload(searchPath, globOptions) { return __awaiter(this, void 0, void 0, function* () { const searchResults = []; @@ -6249,13 +6301,16 @@ function findFilesToUpload(searchPath, globOptions) { core_1.debug(`Removing ${searchResult} from rawSearchResults because it is a directory`); } } - /* - Only a single search pattern is being included so only 1 searchResult is expected. In the future if multiple search patterns are - simultaneously supported this will change - */ + // Calculate the root directory for the artifact using the search paths that were utilized const searchPaths = globber.getSearchPaths(); if (searchPaths.length > 1) { - throw new Error('Only 1 search path should be returned'); + core_1.info(`Multiple search paths detected. Calculating the least common ancestor of all paths`); + const lcaSearchPath = getMultiPathLCA(searchPaths); + core_1.info(`The least common ancestor is ${lcaSearchPath} This will be the root directory of the artifact`); + return { + filesToUpload: searchResults, + rootDirectory: lcaSearchPath + }; } /* Special case for a single file artifact that is uploaded without a directory or wildcard pattern. The directory structure is diff --git a/src/search.ts b/src/search.ts index 02917d7..6737c41 100644 --- a/src/search.ts +++ b/src/search.ts @@ -1,5 +1,6 @@ import * as glob from '@actions/glob' -import {debug} from '@actions/core' +import * as path from 'path' +import {debug, info} from '@actions/core' import {lstatSync} from 'fs' import {dirname} from 'path' @@ -16,6 +17,65 @@ function getDefaultGlobOptions(): glob.GlobOptions { } } +/** + * If multiple paths are specific, the least common ancestor (LCA) of the search paths is used as + * the delimiter to control the directory structure for the artifact. This function returns the LCA + * when given an array of search paths + * + * Example 1: The patterns `/foo/` and `/bar/` returns `/` + * + * Example 2: The patterns `~/foo/bar/*` and `~/foo/voo/two/*` and `~/foo/mo/` returns `~/foo` + */ +function getMultiPathLCA(searchPaths: string[]): string { + if (searchPaths.length < 2) { + throw new Error('At least two search paths must be provided') + } + + const commonPaths = new Array() + const splitPaths = new Array() + let smallestPathLength = Number.MAX_SAFE_INTEGER + + // split each of the search paths using the platform specific separator + for (const searchPath of searchPaths) { + debug(`Using search path ${searchPath}`) + const splitSearchPath = searchPath.split(path.sep) + + // keep track of the smallest path length so that we don't accidentally later go out of bounds + smallestPathLength = Math.min(smallestPathLength, splitSearchPath.length) + splitPaths.push(splitSearchPath) + } + + // on Unix-like file systems, the file separator exists at the beginning of the file path, make sure to preserve it + if (searchPaths[0].startsWith(path.sep)) { + commonPaths.push(path.sep) + } + + let splitIndex = 0 + // function to check if the paths are the same at a specific index + function isPathTheSame(): boolean { + const common = splitPaths[0][splitIndex] + for (let i = 1; i < splitPaths.length; i++) { + if (common !== splitPaths[i][splitIndex]) { + // a non-common index has been reached + return false + } + } + // if all are the same, add to the end result & increment the index + commonPaths.push(common) + splitIndex++ + return true + } + + // Loop over all the search paths until there is a non-common ancestor or we go out of bounds + while (splitIndex < smallestPathLength) { + if (!isPathTheSame()) { + break + } + } + + return path.join(...commonPaths) +} + export async function findFilesToUpload( searchPath: string, globOptions?: glob.GlobOptions @@ -42,13 +102,22 @@ export async function findFilesToUpload( } } - /* - Only a single search pattern is being included so only 1 searchResult is expected. In the future if multiple search patterns are - simultaneously supported this will change - */ + // Calculate the root directory for the artifact using the search paths that were utilized const searchPaths: string[] = globber.getSearchPaths() + if (searchPaths.length > 1) { - throw new Error('Only 1 search path should be returned') + info( + `Multiple search paths detected. Calculating the least common ancestor of all paths` + ) + const lcaSearchPath = getMultiPathLCA(searchPaths) + info( + `The least common ancestor is ${lcaSearchPath} This will be the root directory of the artifact` + ) + + return { + filesToUpload: searchResults, + rootDirectory: lcaSearchPath + } } /*