diff --git a/README.md b/README.md index 1c963a8..5e3a565 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Paths Changes Filter -This [Github Action](https://github.com/features/actions) enables conditional execution of workflow steps and jobs, -based on the files modified by pull request, feature branch or in pushed commits. +[Github Action](https://github.com/features/actions) that enables conditional execution of workflow steps and jobs, based on the files modified by pull request, on a feature +branch, or by the recently pushed commits. -It saves time and resources especially in monorepo setups, where you can run slow tasks (e.g. integration tests or deployments) only for changed components. +Run slow tasks like integration tests or deployments only for changed components. It saves time and resources, especially in monorepo setups. Github workflows built-in [path filters](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#onpushpull_requestpaths) don't allow this because they don't work on a level of individual jobs or steps. @@ -17,27 +17,27 @@ don't allow this because they don't work on a level of individual jobs or steps. - Workflow triggered by **[pull_request](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#pull_request)** or **[pull_request_target](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#pull_request_target)** event - Changes are detected against the pull request base branch - - Uses Github REST API to fetch list of modified files + - Uses Github REST API to fetch a list of modified files - **Feature branches:** - Workflow triggered by **[push](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#push)** or any other **[event](https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows)** - The `base` input parameter must not be the same as the branch that triggered the workflow - - Changes are detected against the merge-base with configured base branch or default branch + - Changes are detected against the merge-base with the configured base branch or the default branch - Uses git commands to detect changes - repository must be already [checked out](https://github.com/actions/checkout) -- **Master, Release or other long-lived branches:** +- **Master, Release, or other long-lived branches:** - Workflow triggered by **[push](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#push)** event - when `base` input parameter is same as the branch that triggered the workflow: + when `base` input parameter is the same as the branch that triggered the workflow: - Changes are detected against the most recent commit on the same branch before the push - Workflow triggered by any other **[event](https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows)** when `base` input parameter is commit SHA: - Changes are detected against the provided `base` commit - Workflow triggered by any other **[event](https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows)** - when `base` input parameter is same as the branch that triggered the workflow: - - Changes are detected from last commit + when `base` input parameter is the same as the branch that triggered the workflow: + - Changes are detected from the last commit - Uses git commands to detect changes - repository must be already [checked out](https://github.com/actions/checkout) - **Local changes** - Workflow triggered by any event when `base` input parameter is set to `HEAD` - - Changes are detected against current HEAD + - Changes are detected against the current HEAD - Untracked files are ignored ## Example @@ -57,10 +57,10 @@ For more scenarios see [examples](#examples) section. ## Notes: - Paths expressions are evaluated using [picomatch](https://github.com/micromatch/picomatch) library. - Documentation for path expression format can be found on project github page. + Documentation for path expression format can be found on the project GitHub page. - Picomatch [dot](https://github.com/micromatch/picomatch#options) option is set to true. - Globbing will match also paths where file or folder name starts with a dot. -- It's recommended to quote your path expressions with `'` or `"`. Otherwise you will get an error if it starts with `*`. + Globbing will also match paths where file or folder name starts with a dot. +- It's recommended to quote your path expressions with `'` or `"`. Otherwise, you will get an error if it starts with `*`. - Local execution with [act](https://github.com/nektos/act) works only with alternative runner image. Default runner doesn't have `git` binary. - Use: `act -P ubuntu-latest=nektos/act-environments-ubuntu:18.04` @@ -72,7 +72,7 @@ For more scenarios see [examples](#examples) section. - Improved listing of matching files with `list-files: shell` and `list-files: escape` options - Paths expressions are now evaluated using [picomatch](https://github.com/micromatch/picomatch) library -For more information see [CHANGELOG](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md) +For more information, see [CHANGELOG](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md) # Usage @@ -80,29 +80,27 @@ For more information see [CHANGELOG](https://github.com/dorny/paths-filter/blob/ - uses: dorny/paths-filter@v2 with: # Defines filters applied to detected changed files. - # Each filter has a name and list of rules. + # Each filter has a name and a list of rules. # Rule is a glob expression - paths of all changed # files are matched against it. # Rule can optionally specify if the file - # should be added, modified or deleted. - # For each filter there will be corresponding output variable to + # should be added, modified, or deleted. + # For each filter, there will be a corresponding output variable to # indicate if there's a changed file matching any of the rules. - # Optionally there can be a second output variable + # Optionally, there can be a second output variable # set to list of all files matching the filter. - # Filters can be provided inline as a string (containing valid YAML document) - # or as a relative path to separate file (e.g.: .github/filters.yaml). - # Multiline string is evaluated as embedded filter definition, - # single line string is evaluated as relative path to separate file. + # Filters can be provided inline as a string (containing valid YAML document), + # or as a relative path to a file (e.g.: .github/filters.yaml). # Filters syntax is documented by example - see examples section. filters: '' - # Branch, tag or commit SHA against which the changes will be detected. - # If it references same branch it was pushed to, + # Branch, tag, or commit SHA against which the changes will be detected. + # If it references the same branch it was pushed to, # changes are detected against the most recent commit before the push. - # Otherwise it uses git merge-base to find best common ancestor between + # Otherwise, it uses git merge-base to find the best common ancestor between # current branch (HEAD) and base. # When merge-base is found, it's used for change detection - only changes - # introduced by current branch are considered. + # introduced by the current branch are considered. # All files are considered as added if there is no common ancestor with # base branch or no previous commit. # This option is ignored if action is triggered by pull_request event. @@ -110,16 +108,16 @@ For more information see [CHANGELOG](https://github.com/dorny/paths-filter/blob/ base: '' # Git reference (e.g. branch name) from which the changes will be detected. - # Useful when workflow can be triggered only on default branch (e.g. repository_dispatch event) - # but you want to get changes on different branch. + # Useful when workflow can be triggered only on the default branch (e.g. repository_dispatch event) + # but you want to get changes on a different branch. # This option is ignored if action is triggered by pull_request event. # default: ${{ github.ref }} ref: - # How many commits are initially fetched from base branch. + # How many commits are initially fetched from the base branch. # If needed, each subsequent fetch doubles the # previously requested number of commits until the merge-base - # is found or there are no more commits in the history. + # is found, or there are no more commits in the history. # This option takes effect only when changes are detected # using git against base branch (feature branch workflow). # Default: 100 @@ -128,11 +126,11 @@ For more information see [CHANGELOG](https://github.com/dorny/paths-filter/blob/ # Enables listing of files matching the filter: # 'none' - Disables listing of matching files (default). # 'csv' - Coma separated list of filenames. - # If needed it uses double quotes to wrap filename with unsafe characters. - # 'json' - Matching files paths are formatted as JSON array. - # 'shell' - Space delimited list usable as command line argument list in Linux shell. - # If needed it uses single or double quotes to wrap filename with unsafe characters. - # 'escape'- Space delimited list usable as command line argument list in Linux shell. + # If needed, it uses double quotes to wrap filename with unsafe characters. + # 'json' - File paths are formatted as JSON array. + # 'shell' - Space delimited list usable as command-line argument list in Linux shell. + # If needed, it uses single or double quotes to wrap filename with unsafe characters. + # 'escape'- Space delimited list usable as command-line argument list in Linux shell. # Backslash escapes every potentially unsafe character. # Default: none list-files: '' @@ -140,23 +138,23 @@ For more information see [CHANGELOG](https://github.com/dorny/paths-filter/blob/ # Relative path under $GITHUB_WORKSPACE where the repository was checked out. working-directory: '' - # Personal access token used to fetch list of changed files + # Personal access token used to fetch a list of changed files # from Github REST API. - # It's used only if action is triggered by pull request event. + # It's only used if action is triggered by a pull request event. # Github token from workflow context is used as default value. - # If empty string is provided, action falls back to detect + # If an empty string is provided, the action falls back to detect # changes using git commands. # Default: ${{ github.token }} token: '' ``` ## Outputs -- For each filter it sets output variable named by the filter to the text: +- For each filter, it sets output variable named by the filter to the text: - `'true'` - if **any** of changed files matches any of filter rules - `'false'` - if **none** of changed files matches any of filter rules -- For each filter it sets output variable with name `${FILTER_NAME}_count` to the count of matching files. -- If enabled, for each filter it sets output variable with name `${FILTER_NAME}_files`. It will contain list of all files matching the filter. -- `changes` - JSON array with names of all filters matching any of changed files. +- For each filter, it sets an output variable with the name `${FILTER_NAME}_count` to the count of matching files. +- If enabled, for each filter it sets an output variable with the name `${FILTER_NAME}_files`. It will contain a list of all files matching the filter. +- `changes` - JSON array with names of all filters matching any of the changed files. # Examples @@ -283,7 +281,7 @@ jobs: ```yaml on: pull_request: - branches: # PRs to following branches will trigger the workflow + branches: # PRs to the following branches will trigger the workflow - master - develop jobs: @@ -329,7 +327,7 @@ jobs: ```yaml on: push: - branches: # Push to following branches will trigger the workflow + branches: # Push to the following branches will trigger the workflow - master - develop - release/** @@ -341,8 +339,8 @@ jobs: - uses: dorny/paths-filter@v2 id: filter with: - # Use context to get branch where commits were pushed. - # If there is only one long lived branch (e.g. master), + # Use context to get the branch where commits were pushed. + # If there is only one long-lived branch (e.g. master), # you can specify it directly. # If it's not configured, the repository default branch is used. base: ${{ github.ref }} @@ -366,11 +364,11 @@ jobs: steps: - uses: actions/checkout@v2 - # Some action which modifies files tracked by git (e.g. code linter) + # Some action that modifies files tracked by git (e.g. code linter) - uses: johndoe/some-action@v1 # Filter to detect which files were modified - # Changes could be for example automatically committed + # Changes could be, for example, automatically committed - uses: dorny/paths-filter@v2 id: filter with: @@ -421,10 +419,10 @@ jobs: id: filter with: # Changed file can be 'added', 'modified', or 'deleted'. - # By default the type of change is not considered. - # Optionally it's possible to specify it using nested - # dictionary, where type(s) of change composes the key. - # Multiple change types can be specified using `|` as delimiter. + # By default, the type of change is not considered. + # Optionally, it's possible to specify it using nested + # dictionary, where the type of change composes the key. + # Multiple change types can be specified using `|` as the delimiter. filters: | shared: &shared - common/** @@ -451,7 +449,7 @@ jobs: # Enable listing of files matching each filter. # Paths to files will be available in `${FILTER_NAME}_files` output variable. # Paths will be escaped and space-delimited. - # Output is usable as command line argument list in Linux shell + # Output is usable as command-line argument list in Linux shell list-files: shell # In this example changed files will be checked by linter. @@ -478,7 +476,7 @@ jobs: # Paths will be formatted as JSON array list-files: json - # In this example all changed files are passed to following action to do + # In this example all changed files are passed to the following action to do # some custom processing. filters: | changed: diff --git a/action.yml b/action.yml index f8a96e1..dc51528 100644 --- a/action.yml +++ b/action.yml @@ -13,7 +13,6 @@ inputs: description: | Git reference (e.g. branch name) from which the changes will be detected. This option is ignored if action is triggered by pull_request event. - default: ${{ github.ref }} required: false base: description: | diff --git a/dist/index.js b/dist/index.js index 914be19..14e671c 100644 --- a/dist/index.js +++ b/dist/index.js @@ -3830,19 +3830,15 @@ async function getChangesInLastCommit() { return parseGitDiffOutput(output); } exports.getChangesInLastCommit = getChangesInLastCommit; -async function getChanges(baseRef) { - if (!(await hasCommit(baseRef))) { - // Fetch single commit - core.startGroup(`Fetching ${baseRef} from origin`); - await exec_1.default('git', ['fetch', '--depth=1', '--no-tags', 'origin', baseRef]); - core.endGroup(); - } +async function getChanges(base, head) { + const baseRef = await ensureRefAvailable(base); + const headRef = await ensureRefAvailable(head); // Get differences between ref and HEAD - core.startGroup(`Change detection ${baseRef}..HEAD`); + core.startGroup(`Change detection ${base}..${head}`); let output = ''; try { // Two dots '..' change detection - directly compares two versions - output = (await exec_1.default('git', ['diff', '--no-renames', '--name-status', '-z', `${baseRef}..HEAD`])).stdout; + output = (await exec_1.default('git', ['diff', '--no-renames', '--name-status', '-z', `${baseRef}..${headRef}`])).stdout; } finally { fixStdOutNullTermination(); @@ -3877,24 +3873,24 @@ async function getChangesSinceMergeBase(base, head, initialFetchDepth) { let noMergeBase = false; core.startGroup(`Searching for merge-base ${base}...${head}`); try { - baseRef = await getFullRef(base); - headRef = await getFullRef(head); + baseRef = await getLocalRef(base); + headRef = await getLocalRef(head); if (!(await hasMergeBase())) { await exec_1.default('git', ['fetch', '--no-tags', `--depth=${initialFetchDepth}`, 'origin', base, head]); if (baseRef === undefined || headRef === undefined) { - baseRef = baseRef !== null && baseRef !== void 0 ? baseRef : (await getFullRef(base)); - headRef = headRef !== null && headRef !== void 0 ? headRef : (await getFullRef(head)); + baseRef = baseRef !== null && baseRef !== void 0 ? baseRef : (await getLocalRef(base)); + headRef = headRef !== null && headRef !== void 0 ? headRef : (await getLocalRef(head)); if (baseRef === undefined || headRef === undefined) { await exec_1.default('git', ['fetch', '--tags', '--depth=1', 'origin', base, head], { ignoreReturnCode: true // returns exit code 1 if tags on remote were updated - we can safely ignore it }); - baseRef = baseRef !== null && baseRef !== void 0 ? baseRef : (await getFullRef(base)); - headRef = headRef !== null && headRef !== void 0 ? headRef : (await getFullRef(head)); + baseRef = baseRef !== null && baseRef !== void 0 ? baseRef : (await getLocalRef(base)); + headRef = headRef !== null && headRef !== void 0 ? headRef : (await getLocalRef(head)); if (baseRef === undefined) { - throw new Error(`Could not determine what is ${base} - fetch works but it's not a branch or tag`); + throw new Error(`Could not determine what is ${base} - fetch works but it's not a branch, tag or commit SHA`); } if (headRef === undefined) { - throw new Error(`Could not determine what is ${head} - fetch works but it's not a branch or tag`); + throw new Error(`Could not determine what is ${head} - fetch works but it's not a branch, tag or commit SHA`); } } } @@ -3971,7 +3967,7 @@ async function listAllFilesAsAdded() { } exports.listAllFilesAsAdded = listAllFilesAsAdded; async function getCurrentRef() { - core.startGroup(`Determining current ref`); + core.startGroup(`Get current git ref`); try { const branch = (await exec_1.default('git', ['branch', '--show-current'])).stdout.trim(); if (branch) { @@ -4005,22 +4001,16 @@ function isGitSha(ref) { } exports.isGitSha = isGitSha; async function hasCommit(ref) { - core.startGroup(`Checking if commit for ${ref} is locally available`); - try { - return (await exec_1.default('git', ['cat-file', '-e', `${ref}^{commit}`], { ignoreReturnCode: true })).code === 0; - } - finally { - core.endGroup(); - } + return (await exec_1.default('git', ['cat-file', '-e', `${ref}^{commit}`], { ignoreReturnCode: true })).code === 0; } async function getCommitCount() { const output = (await exec_1.default('git', ['rev-list', '--count', '--all'])).stdout; const count = parseInt(output); return isNaN(count) ? 0 : count; } -async function getFullRef(shortName) { +async function getLocalRef(shortName) { if (isGitSha(shortName)) { - return shortName; + return (await hasCommit(shortName)) ? shortName : undefined; } const output = (await exec_1.default('git', ['show-ref', shortName], { ignoreReturnCode: true })).stdout; const refs = output @@ -4036,6 +4026,27 @@ async function getFullRef(shortName) { } return refs[0]; } +async function ensureRefAvailable(name) { + core.startGroup(`Ensuring ${name} is fetched from origin`); + try { + let ref = await getLocalRef(name); + if (ref === undefined) { + await exec_1.default('git', ['fetch', '--depth=1', '--no-tags', 'origin', name]); + ref = await getLocalRef(name); + if (ref === undefined) { + await exec_1.default('git', ['fetch', '--depth=1', '--tags', 'origin', name]); + ref = await getLocalRef(name); + if (ref === undefined) { + throw new Error(`Could not determine what is ${name} - fetch works but it's not a branch, tag or commit SHA`); + } + } + } + return ref; + } + finally { + core.endGroup(); + } +} function fixStdOutNullTermination() { // Previous command uses NULL as delimiters and output is printed to stdout. // We have to make sure next thing written to stdout will start on new line. @@ -4736,13 +4747,29 @@ async function getChangedFiles(token, base, ref, initialFetchDepth) { // if base is 'HEAD' only local uncommitted changes will be detected // This is the simplest case as we don't need to fetch more commits or evaluate current/before refs if (base === git.HEAD) { + if (ref) { + core.warning(`'ref' input parameter is ignored when 'base' is set to HEAD`); + } return await git.getChangesOnHead(); } - if (github.context.eventName === 'pull_request' || github.context.eventName === 'pull_request_target') { + const prEvents = ['pull_request', 'pull_request_review', 'pull_request_review_comment', 'pull_request_target']; + if (prEvents.includes(github.context.eventName)) { + if (ref) { + core.warning(`'ref' input parameter is ignored when 'base' is set to HEAD`); + } + if (base) { + core.warning(`'base' input parameter is ignored when action is triggered by pull request event`); + } const pr = github.context.payload.pull_request; if (token) { return await getChangedFilesFromApi(token, pr); } + if (github.context.eventName === 'pull_request_target') { + // pull_request_target is executed in context of base branch and GITHUB_SHA points to last commit in base branch + // Therefor it's not possible to look at changes in last commit + // At the same time we don't want to fetch any code from forked repository + throw new Error(`'token' input parameter is required if action is triggered by 'pull_request_target' event`); + } core.info('Github token is not available - changes will be detected from PRs merge commit'); return await git.getChangesInLastCommit(); } @@ -4752,57 +4779,64 @@ async function getChangedFiles(token, base, ref, initialFetchDepth) { } async function getChangedFilesFromGit(base, head, initialFetchDepth) { var _a; - const defaultRef = (_a = github.context.payload.repository) === null || _a === void 0 ? void 0 : _a.default_branch; + const defaultBranch = (_a = github.context.payload.repository) === null || _a === void 0 ? void 0 : _a.default_branch; const beforeSha = github.context.eventName === 'push' ? github.context.payload.before : null; - const ref = git.getShortName(head || github.context.ref) || - (core.warning(`'ref' field is missing in event payload - using current branch, tag or commit SHA`), - await git.getCurrentRef()); - const baseRef = git.getShortName(base) || defaultRef; - if (!baseRef) { + const currentRef = await git.getCurrentRef(); + head = git.getShortName(head || github.context.ref || currentRef); + base = git.getShortName(base || defaultBranch); + if (!head) { + throw new Error("This action requires 'head' input to be configured, 'ref' to be set in the event payload or branch/tag checked out in current git repository"); + } + if (!base) { throw new Error("This action requires 'base' input to be configured or 'repository.default_branch' to be set in the event payload"); } - const isBaseRefSha = git.isGitSha(baseRef); - const isBaseRefSameAsRef = baseRef === ref; + const isBaseSha = git.isGitSha(base); + const isBaseSameAsHead = base === head; // If base is commit SHA we will do comparison against the referenced commit // Or if base references same branch it was pushed to, we will do comparison against the previously pushed commit - if (isBaseRefSha || isBaseRefSameAsRef) { - if (!isBaseRefSha && !beforeSha) { + if (isBaseSha || isBaseSameAsHead) { + const baseSha = isBaseSha ? base : beforeSha; + if (!baseSha) { core.warning(`'before' field is missing in event payload - changes will be detected from last commit`); + if (head !== currentRef) { + core.warning(`Ref ${head} is not checked out - results might be incorrect!`); + } return await git.getChangesInLastCommit(); } - const baseSha = isBaseRefSha ? baseRef : beforeSha; // If there is no previously pushed commit, // we will do comparison against the default branch or return all as added if (baseSha === git.NULL_SHA) { - if (defaultRef && baseRef !== defaultRef) { - core.info(`First push of a branch detected - changes will be detected against the default branch ${defaultRef}`); - return await git.getChangesSinceMergeBase(defaultRef, ref, initialFetchDepth); + if (defaultBranch && base !== defaultBranch) { + core.info(`First push of a branch detected - changes will be detected against the default branch ${defaultBranch}`); + return await git.getChangesSinceMergeBase(defaultBranch, head, initialFetchDepth); } else { core.info('Initial push detected - all files will be listed as added'); + if (head !== currentRef) { + core.warning(`Ref ${head} is not checked out - results might be incorrect!`); + } return await git.listAllFilesAsAdded(); } } - core.info(`Changes will be detected against commit (${baseSha})`); - return await git.getChanges(baseSha); + core.info(`Changes will be detected between ${baseSha} and ${head}`); + return await git.getChanges(baseSha, head); } - // Changes introduced by current branch against the base branch - core.info(`Changes will be detected against ${baseRef}`); - return await git.getChangesSinceMergeBase(baseRef, ref, initialFetchDepth); + core.info(`Changes will be detected between ${base} and ${head}`); + return await git.getChangesSinceMergeBase(base, head, initialFetchDepth); } // Uses github REST api to get list of files changed in PR -async function getChangedFilesFromApi(token, pullRequest) { - core.startGroup(`Fetching list of changed files for PR#${pullRequest.number} from Github API`); +async function getChangedFilesFromApi(token, prNumber) { + core.startGroup(`Fetching list of changed files for PR#${prNumber.number} from Github API`); try { const client = new github.GitHub(token); const per_page = 100; const files = []; for (let page = 1;; page++) { - core.info(`Invoking listFiles(pull_number: ${pullRequest.number}, page: ${page}, per_page: ${per_page})`); + core.info(`Invoking listFiles(pull_number: ${prNumber.number}, page: ${page}, per_page: ${per_page})`); const response = await client.pulls.listFiles({ owner: github.context.repo.owner, repo: github.context.repo.repo, - pull_number: pullRequest.number, + pull_number: prNumber.number, per_page, page }); diff --git a/src/git.ts b/src/git.ts index df56308..f08d2e5 100644 --- a/src/git.ts +++ b/src/git.ts @@ -18,20 +18,16 @@ export async function getChangesInLastCommit(): Promise { return parseGitDiffOutput(output) } -export async function getChanges(baseRef: string): Promise { - if (!(await hasCommit(baseRef))) { - // Fetch single commit - core.startGroup(`Fetching ${baseRef} from origin`) - await exec('git', ['fetch', '--depth=1', '--no-tags', 'origin', baseRef]) - core.endGroup() - } +export async function getChanges(base: string, head: string): Promise { + const baseRef = await ensureRefAvailable(base) + const headRef = await ensureRefAvailable(head) // Get differences between ref and HEAD - core.startGroup(`Change detection ${baseRef}..HEAD`) + core.startGroup(`Change detection ${base}..${head}`) let output = '' try { // Two dots '..' change detection - directly compares two versions - output = (await exec('git', ['diff', '--no-renames', '--name-status', '-z', `${baseRef}..HEAD`])).stdout + output = (await exec('git', ['diff', '--no-renames', '--name-status', '-z', `${baseRef}..${headRef}`])).stdout } finally { fixStdOutNullTermination() core.endGroup() @@ -67,24 +63,28 @@ export async function getChangesSinceMergeBase(base: string, head: string, initi let noMergeBase = false core.startGroup(`Searching for merge-base ${base}...${head}`) try { - baseRef = await getFullRef(base) - headRef = await getFullRef(head) + baseRef = await getLocalRef(base) + headRef = await getLocalRef(head) if (!(await hasMergeBase())) { await exec('git', ['fetch', '--no-tags', `--depth=${initialFetchDepth}`, 'origin', base, head]) if (baseRef === undefined || headRef === undefined) { - baseRef = baseRef ?? (await getFullRef(base)) - headRef = headRef ?? (await getFullRef(head)) + baseRef = baseRef ?? (await getLocalRef(base)) + headRef = headRef ?? (await getLocalRef(head)) if (baseRef === undefined || headRef === undefined) { await exec('git', ['fetch', '--tags', '--depth=1', 'origin', base, head], { ignoreReturnCode: true // returns exit code 1 if tags on remote were updated - we can safely ignore it }) - baseRef = baseRef ?? (await getFullRef(base)) - headRef = headRef ?? (await getFullRef(head)) + baseRef = baseRef ?? (await getLocalRef(base)) + headRef = headRef ?? (await getLocalRef(head)) if (baseRef === undefined) { - throw new Error(`Could not determine what is ${base} - fetch works but it's not a branch or tag`) + throw new Error( + `Could not determine what is ${base} - fetch works but it's not a branch, tag or commit SHA` + ) } if (headRef === undefined) { - throw new Error(`Could not determine what is ${head} - fetch works but it's not a branch or tag`) + throw new Error( + `Could not determine what is ${head} - fetch works but it's not a branch, tag or commit SHA` + ) } } } @@ -163,7 +163,7 @@ export async function listAllFilesAsAdded(): Promise { } export async function getCurrentRef(): Promise { - core.startGroup(`Determining current ref`) + core.startGroup(`Get current git ref`) try { const branch = (await exec('git', ['branch', '--show-current'])).stdout.trim() if (branch) { @@ -198,12 +198,7 @@ export function isGitSha(ref: string): boolean { } async function hasCommit(ref: string): Promise { - core.startGroup(`Checking if commit for ${ref} is locally available`) - try { - return (await exec('git', ['cat-file', '-e', `${ref}^{commit}`], {ignoreReturnCode: true})).code === 0 - } finally { - core.endGroup() - } + return (await exec('git', ['cat-file', '-e', `${ref}^{commit}`], {ignoreReturnCode: true})).code === 0 } async function getCommitCount(): Promise { @@ -212,9 +207,9 @@ async function getCommitCount(): Promise { return isNaN(count) ? 0 : count } -async function getFullRef(shortName: string): Promise { +async function getLocalRef(shortName: string): Promise { if (isGitSha(shortName)) { - return shortName + return (await hasCommit(shortName)) ? shortName : undefined } const output = (await exec('git', ['show-ref', shortName], {ignoreReturnCode: true})).stdout @@ -235,6 +230,28 @@ async function getFullRef(shortName: string): Promise { return refs[0] } +async function ensureRefAvailable(name: string): Promise { + core.startGroup(`Ensuring ${name} is fetched from origin`) + try { + let ref = await getLocalRef(name) + if (ref === undefined) { + await exec('git', ['fetch', '--depth=1', '--no-tags', 'origin', name]) + ref = await getLocalRef(name) + if (ref === undefined) { + await exec('git', ['fetch', '--depth=1', '--tags', 'origin', name]) + ref = await getLocalRef(name) + if (ref === undefined) { + throw new Error(`Could not determine what is ${name} - fetch works but it's not a branch, tag or commit SHA`) + } + } + } + + return ref + } finally { + core.endGroup() + } +} + function fixStdOutNullTermination(): void { // Previous command uses NULL as delimiters and output is printed to stdout. // We have to make sure next thing written to stdout will start on new line. diff --git a/src/main.ts b/src/main.ts index 466d695..d2cb678 100644 --- a/src/main.ts +++ b/src/main.ts @@ -61,14 +61,30 @@ async function getChangedFiles(token: string, base: string, ref: string, initial // if base is 'HEAD' only local uncommitted changes will be detected // This is the simplest case as we don't need to fetch more commits or evaluate current/before refs if (base === git.HEAD) { + if (ref) { + core.warning(`'ref' input parameter is ignored when 'base' is set to HEAD`) + } return await git.getChangesOnHead() } - if (github.context.eventName === 'pull_request' || github.context.eventName === 'pull_request_target') { + const prEvents = ['pull_request', 'pull_request_review', 'pull_request_review_comment', 'pull_request_target'] + if (prEvents.includes(github.context.eventName)) { + if (ref) { + core.warning(`'ref' input parameter is ignored when 'base' is set to HEAD`) + } + if (base) { + core.warning(`'base' input parameter is ignored when action is triggered by pull request event`) + } const pr = github.context.payload.pull_request as Webhooks.WebhookPayloadPullRequestPullRequest if (token) { return await getChangedFilesFromApi(token, pr) } + if (github.context.eventName === 'pull_request_target') { + // pull_request_target is executed in context of base branch and GITHUB_SHA points to last commit in base branch + // Therefor it's not possible to look at changes in last commit + // At the same time we don't want to fetch any code from forked repository + throw new Error(`'token' input parameter is required if action is triggered by 'pull_request_target' event`) + } core.info('Github token is not available - changes will be detected from PRs merge commit') return await git.getChangesInLastCommit() } else { @@ -77,73 +93,85 @@ async function getChangedFiles(token: string, base: string, ref: string, initial } async function getChangedFilesFromGit(base: string, head: string, initialFetchDepth: number): Promise { - const defaultRef = github.context.payload.repository?.default_branch + const defaultBranch = github.context.payload.repository?.default_branch const beforeSha = github.context.eventName === 'push' ? (github.context.payload as Webhooks.WebhookPayloadPush).before : null - const ref = - git.getShortName(head || github.context.ref) || - (core.warning(`'ref' field is missing in event payload - using current branch, tag or commit SHA`), - await git.getCurrentRef()) + const currentRef = await git.getCurrentRef() - const baseRef = git.getShortName(base) || defaultRef - if (!baseRef) { + head = git.getShortName(head || github.context.ref || currentRef) + base = git.getShortName(base || defaultBranch) + + if (!head) { + throw new Error( + "This action requires 'head' input to be configured, 'ref' to be set in the event payload or branch/tag checked out in current git repository" + ) + } + + if (!base) { throw new Error( "This action requires 'base' input to be configured or 'repository.default_branch' to be set in the event payload" ) } - const isBaseRefSha = git.isGitSha(baseRef) - const isBaseRefSameAsRef = baseRef === ref + const isBaseSha = git.isGitSha(base) + const isBaseSameAsHead = base === head // If base is commit SHA we will do comparison against the referenced commit // Or if base references same branch it was pushed to, we will do comparison against the previously pushed commit - if (isBaseRefSha || isBaseRefSameAsRef) { - if (!isBaseRefSha && !beforeSha) { + if (isBaseSha || isBaseSameAsHead) { + const baseSha = isBaseSha ? base : beforeSha + if (!baseSha) { core.warning(`'before' field is missing in event payload - changes will be detected from last commit`) + if (head !== currentRef) { + core.warning(`Ref ${head} is not checked out - results might be incorrect!`) + } return await git.getChangesInLastCommit() } - const baseSha = isBaseRefSha ? baseRef : beforeSha // If there is no previously pushed commit, // we will do comparison against the default branch or return all as added if (baseSha === git.NULL_SHA) { - if (defaultRef && baseRef !== defaultRef) { - core.info(`First push of a branch detected - changes will be detected against the default branch ${defaultRef}`) - return await git.getChangesSinceMergeBase(defaultRef, ref, initialFetchDepth) + if (defaultBranch && base !== defaultBranch) { + core.info( + `First push of a branch detected - changes will be detected against the default branch ${defaultBranch}` + ) + return await git.getChangesSinceMergeBase(defaultBranch, head, initialFetchDepth) } else { core.info('Initial push detected - all files will be listed as added') + if (head !== currentRef) { + core.warning(`Ref ${head} is not checked out - results might be incorrect!`) + } return await git.listAllFilesAsAdded() } } - core.info(`Changes will be detected against commit (${baseSha})`) - return await git.getChanges(baseSha) + core.info(`Changes will be detected between ${baseSha} and ${head}`) + return await git.getChanges(baseSha, head) } - // Changes introduced by current branch against the base branch - core.info(`Changes will be detected against ${baseRef}`) - return await git.getChangesSinceMergeBase(baseRef, ref, initialFetchDepth) + core.info(`Changes will be detected between ${base} and ${head}`) + return await git.getChangesSinceMergeBase(base, head, initialFetchDepth) } // Uses github REST api to get list of files changed in PR async function getChangedFilesFromApi( token: string, - pullRequest: Webhooks.WebhookPayloadPullRequestPullRequest + prNumber: Webhooks.WebhookPayloadPullRequestPullRequest ): Promise { - core.startGroup(`Fetching list of changed files for PR#${pullRequest.number} from Github API`) + core.startGroup(`Fetching list of changed files for PR#${prNumber.number} from Github API`) try { const client = new github.GitHub(token) const per_page = 100 const files: File[] = [] for (let page = 1; ; page++) { - core.info(`Invoking listFiles(pull_number: ${pullRequest.number}, page: ${page}, per_page: ${per_page})`) + core.info(`Invoking listFiles(pull_number: ${prNumber.number}, page: ${page}, per_page: ${per_page})`) const response = await client.pulls.listFiles({ owner: github.context.repo.owner, repo: github.context.repo.repo, - pull_number: pullRequest.number, + pull_number: prNumber.number, per_page, page })