diff --git a/package.json b/package.json index 92db89a..082440d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "typescript-action", "description": "GitHub Actions TypeScript template", - "version": "0.0.0", + "version": "0.0.1", "author": "", "private": true, "homepage": "https://github.com/actions/typescript-action", @@ -12,11 +12,7 @@ "bugs": { "url": "https://github.com/actions/typescript-action/issues" }, - "keywords": [ - "actions", - "node", - "setup" - ], + "keywords": ["actions", "node", "setup"], "exports": { ".": "./dist/index.js" }, @@ -33,7 +29,7 @@ "package": "ncc build src/index.ts --license licenses.txt", "package:watch": "npm run package -- --watch", "test": "jest", - "all": "npm run format:write && npm run lint && npm run test && npm run coverage && npm run package" + "all": "npm run format:write && npm run test && npm run coverage && npm run package" }, "license": "MIT", "jest": { @@ -41,29 +37,15 @@ "verbose": true, "clearMocks": true, "testEnvironment": "node", - "moduleFileExtensions": [ - "js", - "ts" - ], - "testMatch": [ - "**/*.test.ts" - ], - "testPathIgnorePatterns": [ - "/node_modules/", - "/dist/" - ], + "moduleFileExtensions": ["js", "ts"], + "testMatch": ["**/*.test.ts"], + "testPathIgnorePatterns": ["/node_modules/", "/dist/"], "transform": { "^.+\\.ts$": "ts-jest" }, - "coverageReporters": [ - "json-summary", - "text", - "lcov" - ], + "coverageReporters": ["json-summary", "text", "lcov"], "collectCoverage": true, - "collectCoverageFrom": [ - "./src/**" - ] + "collectCoverageFrom": ["./src/**"] }, "dependencies": { "@actions/core": "^1.10.1", diff --git a/src/io-utils.ts b/src/io-utils.ts index 010be25..1114fee 100644 --- a/src/io-utils.ts +++ b/src/io-utils.ts @@ -1,5 +1,14 @@ import { Dirent } from 'fs' import fs from 'fs' +import * as glob from '@actions/glob' +import { stat } from 'fs' +import * as path from 'path' + +const stats = promisify(stat) +import { debug, info } from '@actions/core' +import { promisify } from 'util' +import { dirname } from 'path' + export function findReleaseFiles(releaseDir: string): Dirent[] | undefined { const releaseFiles = fs @@ -13,3 +22,154 @@ export function findReleaseFiles(releaseDir: string): Dirent[] | undefined { return releaseFiles } } + +/** + * 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 = path.normalize(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 compare = splitPaths[0][splitIndex] + for (let i = 1; i < splitPaths.length; i++) { + if (compare !== splitPaths[i][splitIndex]) { + // a non-common index has been reached + return false + } + } + 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 + } + // if all are the same, add to the end result & increment the index + commonPaths.push(splitPaths[0][splitIndex]) + splitIndex++ + } + return path.join(...commonPaths) +} + +function getDefaultGlobOptions(): glob.GlobOptions { + return { + followSymbolicLinks: true, + implicitDescendants: true, + omitBrokenSymbolicLinks: true + } +} + +export interface SearchResult { + filesToUpload: string[] + rootDirectory: string +} + +export async function findFilesToUpload( + searchPath: string, + globOptions?: glob.GlobOptions +): Promise { + const searchResults: string[] = [] + const globber = await glob.create( + searchPath, + globOptions || getDefaultGlobOptions() + ) + const rawSearchResults: string[] = await globber.glob() + + /* + Files are saved with case insensitivity. Uploading both a.txt and A.txt will files to be overwritten + Detect any files that could be overwritten for user awareness + */ + const set = new Set() + + /* + Directories will be rejected if attempted to be uploaded. This includes just empty + directories so filter any directories out from the raw search results + */ + for (const searchResult of rawSearchResults) { + const fileStats = await stats(searchResult) + // isDirectory() returns false for symlinks if using fs.lstat(), make sure to use fs.stat() instead + if (!fileStats.isDirectory()) { + debug(`File:${ searchResult } was found using the provided searchPath`) + searchResults.push(searchResult) + + // detect any files that would be overwritten because of case insensitivity + if (set.has(searchResult.toLowerCase())) { + info( + `Uploads are case insensitive: ${ searchResult } was detected that it will be overwritten by another file with the same path` + ) + } else { + set.add(searchResult.toLowerCase()) + } + } else { + debug( + `Removing ${ searchResult } from rawSearchResults because it is a directory` + ) + } + } + + // Calculate the root directory for the artifact using the search paths that were utilized + const searchPaths: string[] = globber.getSearchPaths() + + if (searchPaths.length > 1) { + 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 + } + } + + /* + Special case for a single file artifact that is uploaded without a directory or wildcard pattern. The directory structure is + not preserved and the root directory will be the single files parent directory + */ + if (searchResults.length === 1 && searchPaths[0] === searchResults[0]) { + return { + filesToUpload: searchResults, + rootDirectory: dirname(searchResults[0]) + } + } + + return { + filesToUpload: searchResults, + rootDirectory: searchPaths[0] + } +} diff --git a/src/main.ts b/src/main.ts index bb81ff0..805e890 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,10 +1,7 @@ import * as core from '@actions/core' -import { wait } from './wait' import * as fs from 'fs' import path from 'path' -import { readFileSync, lstatSync } from 'fs' -const axios = require('axios') -const FormData = require('form-data') +import { findReleaseFiles } from './io-utils' /** * The main function for the action. @@ -12,6 +9,9 @@ const FormData = require('form-data') */ export async function run(): Promise { try { + const axios = require('axios') + const FormData = require('form-data') + const apiKey: string = core.getInput('apiKey') const packageName: string = core.getInput('packageName') const aabFile: string = core.getInput('aabFile') @@ -20,8 +20,6 @@ export async function run(): Promise { const keystoreAlias: string = core.getInput('keystoreAlias') const keystorePassword: string = core.getInput('keystorePassword') - const axios = require('axios') - const headers = { Authorization: `Bearer ${apiKey}` } @@ -29,8 +27,13 @@ export async function run(): Promise { const signingKey = path.join('signingFile', 'signingKey.jks') fs.writeFileSync(signingKey, signingKeyBase64, 'base64') + const releaseFiles = findReleaseFiles(aabFile) + if (!releaseFiles || releaseFiles.length || releaseFiles.length !== 1) { + throw new Error('No release files found') + } + const formData = new FormData() - formData.append('file', fs.createReadStream(aabFile)) + formData.append('file', fs.createReadStream(releaseFiles[0].path)) formData.append('file', fs.createReadStream(signingKey)) formData.append('keyPassword', keyPassword) formData.append('keystoreAlias', keystoreAlias)