diff --git a/CHANGELOG.md b/CHANGELOG.md index 2914f60..6f40def 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,20 @@ # Changelog +## v2.3.1 + +- [Fix default branch resolution for .wiki and when using SSH](https://github.com/actions/checkout/pull/284) + + +## v2.3.0 + +- [Fallback to the default branch](https://github.com/actions/checkout/pull/278) + +## v2.2.0 + +- [Fetch all history for all tags and branches when fetch-depth=0](https://github.com/actions/checkout/pull/258) + ## v2.1.1 + - Changes to support GHES ([here](https://github.com/actions/checkout/pull/236) and [here](https://github.com/actions/checkout/pull/248)) ## v2.1.0 diff --git a/README.md b/README.md index 646065d..c2bd069 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ This action checks-out your repository under `$GITHUB_WORKSPACE`, so your workflow can access it. -Only a single commit is fetched by default, for the ref/SHA that triggered the workflow. Set `fetch-depth` to fetch more history. Refer [here](https://help.github.com/en/articles/events-that-trigger-workflows) to learn which commit `$GITHUB_SHA` points to for different events. +Only a single commit is fetched by default, for the ref/SHA that triggered the workflow. Set `fetch-depth: 0` to fetch all history for all branches and tags. Refer [here](https://help.github.com/en/articles/events-that-trigger-workflows) to learn which commit `$GITHUB_SHA` points to for different events. The auth token is persisted in the local git config. This enables your scripts to run authenticated git commands. The token is removed during post-job cleanup. Set `persist-credentials: false` to opt-out. @@ -42,7 +42,7 @@ Refer [here](https://github.com/actions/checkout/blob/v1/README.md) for previous # The branch, tag or SHA to checkout. When checking out the repository that # triggered a workflow, this defaults to the reference or SHA for that event. - # Otherwise, defaults to `master`. + # Otherwise, uses the default branch. ref: '' # Personal access token (PAT) used to fetch the repository. The PAT is configured @@ -89,7 +89,7 @@ Refer [here](https://github.com/actions/checkout/blob/v1/README.md) for previous # Default: true clean: '' - # Number of commits to fetch. 0 indicates all history. + # Number of commits to fetch. 0 indicates all history for all branches and tags. # Default: 1 fetch-depth: '' @@ -110,6 +110,7 @@ Refer [here](https://github.com/actions/checkout/blob/v1/README.md) for previous # Scenarios +- [Fetch all history for all tags and branches](#Fetch-all-history-for-all-tags-and-branches) - [Checkout a different branch](#Checkout-a-different-branch) - [Checkout HEAD^](#Checkout-HEAD) - [Checkout multiple repos (side by side)](#Checkout-multiple-repos-side-by-side) @@ -117,9 +118,14 @@ Refer [here](https://github.com/actions/checkout/blob/v1/README.md) for previous - [Checkout multiple repos (private)](#Checkout-multiple-repos-private) - [Checkout pull request HEAD commit instead of merge commit](#Checkout-pull-request-HEAD-commit-instead-of-merge-commit) - [Checkout pull request on closed event](#Checkout-pull-request-on-closed-event) -- [Fetch all tags](#Fetch-all-tags) -- [Fetch all branches](#Fetch-all-branches) -- [Fetch all history for all tags and branches](#Fetch-all-history-for-all-tags-and-branches) + +## Fetch all history for all tags and branches + +```yaml +- uses: actions/checkout@v2 + with: + fetch-depth: 0 +``` ## Checkout a different branch @@ -207,29 +213,6 @@ jobs: - uses: actions/checkout@v2 ``` -## Fetch all tags - -```yaml -- uses: actions/checkout@v2 -- run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* -``` - -## Fetch all branches - -```yaml -- uses: actions/checkout@v2 -- run: | - git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* -``` - -## Fetch all history for all tags and branches - -```yaml -- uses: actions/checkout@v2 -- run: | - git fetch --prune --unshallow -``` - # License The scripts and documentation in this project are released under the [MIT License](LICENSE) diff --git a/__test__/git-auth-helper.test.ts b/__test__/git-auth-helper.test.ts index 1d5c3d5..e4e640c 100644 --- a/__test__/git-auth-helper.test.ts +++ b/__test__/git-auth-helper.test.ts @@ -714,6 +714,7 @@ async function setup(testName: string): Promise { ), env: {}, fetch: jest.fn(), + getDefaultBranch: jest.fn(), getWorkingDirectory: jest.fn(() => workspace), init: jest.fn(), isDetached: jest.fn(), @@ -722,9 +723,11 @@ async function setup(testName: string): Promise { log1: jest.fn(), remoteAdd: jest.fn(), removeEnvironmentVariable: jest.fn((name: string) => delete git.env[name]), + revParse: jest.fn(), setEnvironmentVariable: jest.fn((name: string, value: string) => { git.env[name] = value }), + shaExists: jest.fn(), submoduleForeach: jest.fn(async () => { return '' }), diff --git a/__test__/git-directory-helper.test.ts b/__test__/git-directory-helper.test.ts index c39a2a5..70849b5 100644 --- a/__test__/git-directory-helper.test.ts +++ b/__test__/git-directory-helper.test.ts @@ -9,6 +9,7 @@ const testWorkspace = path.join(__dirname, '_temp', 'git-directory-helper') let repositoryPath: string let repositoryUrl: string let clean: boolean +let ref: string let git: IGitCommandManager describe('git-directory-helper tests', () => { @@ -41,7 +42,8 @@ describe('git-directory-helper tests', () => { git, repositoryPath, repositoryUrl, - clean + clean, + ref ) // Assert @@ -63,7 +65,8 @@ describe('git-directory-helper tests', () => { git, repositoryPath, repositoryUrl, - clean + clean, + ref ) // Assert @@ -88,7 +91,8 @@ describe('git-directory-helper tests', () => { git, repositoryPath, repositoryUrl, - clean + clean, + ref ) // Assert @@ -109,7 +113,8 @@ describe('git-directory-helper tests', () => { git, repositoryPath, repositoryUrl, - clean + clean, + ref ) // Assert @@ -137,7 +142,8 @@ describe('git-directory-helper tests', () => { git, repositoryPath, repositoryUrl, - clean + clean, + ref ) // Assert @@ -163,7 +169,8 @@ describe('git-directory-helper tests', () => { git, repositoryPath, differentRepositoryUrl, - clean + clean, + ref ) // Assert @@ -187,7 +194,8 @@ describe('git-directory-helper tests', () => { git, repositoryPath, repositoryUrl, - clean + clean, + ref ) // Assert @@ -212,7 +220,8 @@ describe('git-directory-helper tests', () => { git, repositoryPath, repositoryUrl, - clean + clean, + ref ) // Assert @@ -236,7 +245,8 @@ describe('git-directory-helper tests', () => { undefined, repositoryPath, repositoryUrl, - clean + clean, + ref ) // Assert @@ -260,7 +270,8 @@ describe('git-directory-helper tests', () => { git, repositoryPath, repositoryUrl, - clean + clean, + ref ) // Assert @@ -290,7 +301,8 @@ describe('git-directory-helper tests', () => { git, repositoryPath, repositoryUrl, - clean + clean, + ref ) // Assert @@ -305,29 +317,66 @@ describe('git-directory-helper tests', () => { expect(git.tryReset).not.toHaveBeenCalled() }) - const removesRemoteBranches = 'removes local branches' - it(removesRemoteBranches, async () => { + const removesAncestorRemoteBranch = 'removes ancestor remote branch' + it(removesAncestorRemoteBranch, async () => { // Arrange - await setup(removesRemoteBranches) + await setup(removesAncestorRemoteBranch) await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '') const mockBranchList = git.branchList as jest.Mock mockBranchList.mockImplementation(async (remote: boolean) => { - return remote ? ['remote-branch-1', 'remote-branch-2'] : [] + return remote ? ['origin/remote-branch-1', 'origin/remote-branch-2'] : [] }) + ref = 'remote-branch-1/conflict' // Act await gitDirectoryHelper.prepareExistingDirectory( git, repositoryPath, repositoryUrl, - clean + clean, + ref ) // Assert const files = await fs.promises.readdir(repositoryPath) expect(files.sort()).toEqual(['.git', 'my-file']) - expect(git.branchDelete).toHaveBeenCalledWith(true, 'remote-branch-1') - expect(git.branchDelete).toHaveBeenCalledWith(true, 'remote-branch-2') + expect(git.branchDelete).toHaveBeenCalledTimes(1) + expect(git.branchDelete).toHaveBeenCalledWith( + true, + 'origin/remote-branch-1' + ) + }) + + const removesDescendantRemoteBranches = 'removes descendant remote branch' + it(removesDescendantRemoteBranches, async () => { + // Arrange + await setup(removesDescendantRemoteBranches) + await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '') + const mockBranchList = git.branchList as jest.Mock + mockBranchList.mockImplementation(async (remote: boolean) => { + return remote + ? ['origin/remote-branch-1/conflict', 'origin/remote-branch-2'] + : [] + }) + ref = 'remote-branch-1' + + // Act + await gitDirectoryHelper.prepareExistingDirectory( + git, + repositoryPath, + repositoryUrl, + clean, + ref + ) + + // Assert + const files = await fs.promises.readdir(repositoryPath) + expect(files.sort()).toEqual(['.git', 'my-file']) + expect(git.branchDelete).toHaveBeenCalledTimes(1) + expect(git.branchDelete).toHaveBeenCalledWith( + true, + 'origin/remote-branch-1/conflict' + ) }) }) @@ -344,6 +393,9 @@ async function setup(testName: string): Promise { // Clean clean = true + // Ref + ref = '' + // Git command manager git = { branchDelete: jest.fn(), @@ -356,6 +408,7 @@ async function setup(testName: string): Promise { config: jest.fn(), configExists: jest.fn(), fetch: jest.fn(), + getDefaultBranch: jest.fn(), getWorkingDirectory: jest.fn(() => repositoryPath), init: jest.fn(), isDetached: jest.fn(), @@ -364,7 +417,9 @@ async function setup(testName: string): Promise { log1: jest.fn(), remoteAdd: jest.fn(), removeEnvironmentVariable: jest.fn(), + revParse: jest.fn(), setEnvironmentVariable: jest.fn(), + shaExists: jest.fn(), submoduleForeach: jest.fn(), submoduleSync: jest.fn(), submoduleUpdate: jest.fn(), diff --git a/__test__/input-helper.test.ts b/__test__/input-helper.test.ts index 00732ef..920bc8e 100644 --- a/__test__/input-helper.test.ts +++ b/__test__/input-helper.test.ts @@ -110,13 +110,6 @@ describe('input-helper tests', () => { ) }) - it('sets correct default ref/sha for other repo', () => { - inputs.repository = 'some-owner/some-other-repo' - const settings: IGitSourceSettings = inputHelper.getInputs() - expect(settings.ref).toBe('refs/heads/master') - expect(settings.commit).toBeFalsy() - }) - it('sets ref to empty when explicit sha', () => { inputs.ref = '1111111111222222222233333333334444444444' const settings: IGitSourceSettings = inputHelper.getInputs() diff --git a/__test__/verify-no-unstaged-changes.sh b/__test__/verify-no-unstaged-changes.sh index 9fe6173..9b30471 100755 --- a/__test__/verify-no-unstaged-changes.sh +++ b/__test__/verify-no-unstaged-changes.sh @@ -12,6 +12,6 @@ if [[ "$(git status --porcelain)" != "" ]]; then echo ---------------------------------------- echo Troubleshooting echo ---------------------------------------- - echo "::error::Unstaged changes detected. Locally try running: git clean -ffdx && npm ci && npm run all" + echo "::error::Unstaged changes detected. Locally try running: git clean -ffdx && npm ci && npm run format && npm run build" exit 1 fi diff --git a/action.yml b/action.yml index 58e11b7..91d3982 100644 --- a/action.yml +++ b/action.yml @@ -8,7 +8,7 @@ inputs: description: > The branch, tag or SHA to checkout. When checking out the repository that triggered a workflow, this defaults to the reference or SHA for that - event. Otherwise, defaults to `master`. + event. Otherwise, uses the default branch. token: description: > Personal access token (PAT) used to fetch the repository. The PAT is configured @@ -54,7 +54,7 @@ inputs: description: 'Whether to execute `git clean -ffdx && git reset --hard HEAD` before fetching' default: true fetch-depth: - description: 'Number of commits to fetch. 0 indicates all history.' + description: 'Number of commits to fetch. 0 indicates all history for all branches and tags.' default: 1 lfs: description: 'Whether to download Git-LFS files' diff --git a/adrs/0153-checkout-v2.md b/adrs/0153-checkout-v2.md index b9536a5..f174b1a 100644 --- a/adrs/0153-checkout-v2.md +++ b/adrs/0153-checkout-v2.md @@ -70,7 +70,7 @@ We want to take this opportunity to make behavioral changes, from v1. This docum description: 'Whether to execute `git clean -ffdx && git reset --hard HEAD` before fetching' default: true fetch-depth: - description: 'Number of commits to fetch. 0 indicates all history.' + description: 'Number of commits to fetch. 0 indicates all history for all tags and branches.' default: 1 lfs: description: 'Whether to download Git-LFS files' diff --git a/dist/index.js b/dist/index.js index 0074e0a..e0d0238 100644 --- a/dist/index.js +++ b/dist/index.js @@ -3359,7 +3359,7 @@ module.exports = {"name":"@octokit/rest","version":"16.43.1","publishConfig":{"a /***/ }), /***/ 227: -/***/ (function(__unusedmodule, exports) { +/***/ (function(__unusedmodule, exports, __webpack_require__) { "use strict"; @@ -3372,7 +3372,18 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; + result["default"] = mod; + return result; +}; Object.defineProperty(exports, "__esModule", { value: true }); +const url_1 = __webpack_require__(835); +const core = __importStar(__webpack_require__(470)); +const github = __importStar(__webpack_require__(469)); +exports.tagsRefSpec = '+refs/tags/*:refs/tags/*'; function getCheckoutInfo(git, ref, commit) { return __awaiter(this, void 0, void 0, function* () { if (!git) { @@ -3419,6 +3430,15 @@ function getCheckoutInfo(git, ref, commit) { }); } exports.getCheckoutInfo = getCheckoutInfo; +function getRefSpecForAllHistory(ref, commit) { + const result = ['+refs/heads/*:refs/remotes/origin/*', exports.tagsRefSpec]; + if (ref && ref.toUpperCase().startsWith('REFS/PULL/')) { + const branch = ref.substring('refs/pull/'.length); + result.push(`+${commit || ref}:refs/remotes/pull/${branch}`); + } + return result; +} +exports.getRefSpecForAllHistory = getRefSpecForAllHistory; function getRefSpec(ref, commit) { if (!ref && !commit) { throw new Error('Args ref and commit cannot both be empty'); @@ -3468,6 +3488,129 @@ function getRefSpec(ref, commit) { } } exports.getRefSpec = getRefSpec; +/** + * Tests whether the initial fetch created the ref at the expected commit + */ +function testRef(git, ref, commit) { + return __awaiter(this, void 0, void 0, function* () { + if (!git) { + throw new Error('Arg git cannot be empty'); + } + if (!ref && !commit) { + throw new Error('Args ref and commit cannot both be empty'); + } + // No SHA? Nothing to test + if (!commit) { + return true; + } + // SHA only? + else if (!ref) { + return yield git.shaExists(commit); + } + const upperRef = ref.toUpperCase(); + // refs/heads/ + if (upperRef.startsWith('REFS/HEADS/')) { + const branch = ref.substring('refs/heads/'.length); + return ((yield git.branchExists(true, `origin/${branch}`)) && + commit === (yield git.revParse(`refs/remotes/origin/${branch}`))); + } + // refs/pull/ + else if (upperRef.startsWith('REFS/PULL/')) { + // Assume matches because fetched using the commit + return true; + } + // refs/tags/ + else if (upperRef.startsWith('REFS/TAGS/')) { + const tagName = ref.substring('refs/tags/'.length); + return ((yield git.tagExists(tagName)) && commit === (yield git.revParse(ref))); + } + // Unexpected + else { + core.debug(`Unexpected ref format '${ref}' when testing ref info`); + return true; + } + }); +} +exports.testRef = testRef; +function checkCommitInfo(token, commitInfo, repositoryOwner, repositoryName, ref, commit) { + return __awaiter(this, void 0, void 0, function* () { + try { + // GHES? + if (isGhes()) { + return; + } + // Auth token? + if (!token) { + return; + } + // Public PR synchronize, for workflow repo? + if (fromPayload('repository.private') !== false || + github.context.eventName !== 'pull_request' || + fromPayload('action') !== 'synchronize' || + repositoryOwner !== github.context.repo.owner || + repositoryName !== github.context.repo.repo || + ref !== github.context.ref || + !ref.startsWith('refs/pull/') || + commit !== github.context.sha) { + return; + } + // Head SHA + const expectedHeadSha = fromPayload('after'); + if (!expectedHeadSha) { + core.debug('Unable to determine head sha'); + return; + } + // Base SHA + const expectedBaseSha = fromPayload('pull_request.base.sha'); + if (!expectedBaseSha) { + core.debug('Unable to determine base sha'); + return; + } + // Expected message? + const expectedMessage = `Merge ${expectedHeadSha} into ${expectedBaseSha}`; + if (commitInfo.indexOf(expectedMessage) >= 0) { + return; + } + // Extract details from message + const match = commitInfo.match(/Merge ([0-9a-f]{40}) into ([0-9a-f]{40})/); + if (!match) { + core.debug('Unexpected message format'); + return; + } + // Post telemetry + const actualHeadSha = match[1]; + if (actualHeadSha !== expectedHeadSha) { + core.debug(`Expected head sha ${expectedHeadSha}; actual head sha ${actualHeadSha}`); + const octokit = new github.GitHub(token, { + userAgent: `actions-checkout-tracepoint/1.0 (code=STALE_MERGE;owner=${repositoryOwner};repo=${repositoryName};pr=${fromPayload('number')};run_id=${process.env['GITHUB_RUN_ID']};expected_head_sha=${expectedHeadSha};actual_head_sha=${actualHeadSha})` + }); + yield octokit.repos.get({ owner: repositoryOwner, repo: repositoryName }); + } + } + catch (err) { + core.debug(`Error when validating commit info: ${err.stack}`); + } + }); +} +exports.checkCommitInfo = checkCommitInfo; +function fromPayload(path) { + return select(github.context.payload, path); +} +function select(obj, path) { + if (!obj) { + return undefined; + } + const i = path.indexOf('.'); + if (i < 0) { + return obj[path]; + } + const key = path.substr(0, i); + return select(obj[key], path.substr(i + 1)); +} +function isGhes() { + const ghUrl = new url_1.URL(process.env['GITHUB_SERVER_URL'] || 'https://github.com'); + return ghUrl.hostname.toUpperCase() !== 'GITHUB.COM'; +} /***/ }), @@ -5545,6 +5688,7 @@ const exec = __importStar(__webpack_require__(986)); const fshelper = __importStar(__webpack_require__(618)); const io = __importStar(__webpack_require__(1)); const path = __importStar(__webpack_require__(622)); +const refHelper = __importStar(__webpack_require__(227)); const regexpHelper = __importStar(__webpack_require__(528)); const retryHelper = __importStar(__webpack_require__(587)); const git_version_1 = __webpack_require__(559); @@ -5660,18 +5804,14 @@ class GitCommandManager { return output.exitCode === 0; }); } - fetch(fetchDepth, refSpec) { + fetch(refSpec, fetchDepth) { return __awaiter(this, void 0, void 0, function* () { - const args = [ - '-c', - 'protocol.version=2', - 'fetch', - '--no-tags', - '--prune', - '--progress', - '--no-recurse-submodules' - ]; - if (fetchDepth > 0) { + const args = ['-c', 'protocol.version=2', 'fetch']; + if (!refSpec.some(x => x === refHelper.tagsRefSpec)) { + args.push('--no-tags'); + } + args.push('--prune', '--progress', '--no-recurse-submodules'); + if (fetchDepth && fetchDepth > 0) { args.push(`--depth=${fetchDepth}`); } else if (fshelper.fileExistsSync(path.join(this.workingDirectory, '.git', 'shallow'))) { @@ -5687,6 +5827,33 @@ class GitCommandManager { })); }); } + getDefaultBranch(repositoryUrl) { + return __awaiter(this, void 0, void 0, function* () { + let output; + yield retryHelper.execute(() => __awaiter(this, void 0, void 0, function* () { + output = yield this.execGit([ + 'ls-remote', + '--quiet', + '--exit-code', + '--symref', + repositoryUrl, + 'HEAD' + ]); + })); + if (output) { + // Satisfy compiler, will always be set + for (let line of output.stdout.trim().split('\n')) { + line = line.trim(); + if (line.startsWith('ref:') || line.endsWith('HEAD')) { + return line + .substr('ref:'.length, line.length - 'ref:'.length - 'HEAD'.length) + .trim(); + } + } + } + throw new Error('Unexpected output when retrieving default branch'); + }); + } getWorkingDirectory() { return this.workingDirectory; } @@ -5718,7 +5885,8 @@ class GitCommandManager { } log1() { return __awaiter(this, void 0, void 0, function* () { - yield this.execGit(['log', '-1']); + const output = yield this.execGit(['log', '-1']); + return output.stdout; }); } remoteAdd(remoteName, remoteUrl) { @@ -5729,9 +5897,28 @@ class GitCommandManager { removeEnvironmentVariable(name) { delete this.gitEnv[name]; } + /** + * Resolves a ref to a SHA. For a branch or lightweight tag, the commit SHA is returned. + * For an annotated tag, the tag SHA is returned. + * @param {string} ref For example: 'refs/heads/master' or '/refs/tags/v1' + * @returns {Promise} + */ + revParse(ref) { + return __awaiter(this, void 0, void 0, function* () { + const output = yield this.execGit(['rev-parse', ref]); + return output.stdout.trim(); + }); + } setEnvironmentVariable(name, value) { this.gitEnv[name] = value; } + shaExists(sha) { + return __awaiter(this, void 0, void 0, function* () { + const args = ['rev-parse', '--verify', '--quiet', `${sha}^{object}`]; + const output = yield this.execGit(args, true); + return output.exitCode === 0; + }); + } submoduleForeach(command, recursive) { return __awaiter(this, void 0, void 0, function* () { const args = ['submodule', 'foreach']; @@ -5970,7 +6157,7 @@ function getSource(settings) { core.endGroup(); // Prepare existing directory, otherwise recreate if (isExisting) { - yield gitDirectoryHelper.prepareExistingDirectory(git, settings.repositoryPath, repositoryUrl, settings.clean); + yield gitDirectoryHelper.prepareExistingDirectory(git, settings.repositoryPath, repositoryUrl, settings.clean, settings.ref); } if (!git) { // Downloading using REST API @@ -6006,14 +6193,38 @@ function getSource(settings) { core.startGroup('Setting up auth'); yield authHelper.configureAuth(); core.endGroup(); + // Determine the default branch + if (!settings.ref && !settings.commit) { + core.startGroup('Determining the default branch'); + if (settings.sshKey) { + settings.ref = yield git.getDefaultBranch(repositoryUrl); + } + else { + settings.ref = yield githubApiHelper.getDefaultBranch(settings.authToken, settings.repositoryOwner, settings.repositoryName); + } + core.endGroup(); + } // LFS install if (settings.lfs) { yield git.lfsInstall(); } // Fetch core.startGroup('Fetching the repository'); - const refSpec = refHelper.getRefSpec(settings.ref, settings.commit); - yield git.fetch(settings.fetchDepth, refSpec); + if (settings.fetchDepth <= 0) { + // Fetch all branches and tags + let refSpec = refHelper.getRefSpecForAllHistory(settings.ref, settings.commit); + yield git.fetch(refSpec); + // When all history is fetched, the ref we're interested in may have moved to a different + // commit (push or force push). If so, fetch again with a targeted refspec. + if (!(yield refHelper.testRef(git, settings.ref, settings.commit))) { + refSpec = refHelper.getRefSpec(settings.ref, settings.commit); + yield git.fetch(refSpec); + } + } + else { + const refSpec = refHelper.getRefSpec(settings.ref, settings.commit); + yield git.fetch(refSpec, settings.fetchDepth); + } core.endGroup(); // Checkout info core.startGroup('Determining the checkout info'); @@ -6057,7 +6268,9 @@ function getSource(settings) { } } // Dump some info about the checked out commit - yield git.log1(); + const commitInfo = yield git.log1(); + // Check for incorrect pull request merge commit + yield refHelper.checkCommitInfo(settings.authToken, commitInfo, settings.repositoryOwner, settings.repositoryName, settings.ref, settings.commit); } finally { // Remove auth @@ -7362,7 +7575,7 @@ const fs = __importStar(__webpack_require__(747)); const fsHelper = __importStar(__webpack_require__(618)); const io = __importStar(__webpack_require__(1)); const path = __importStar(__webpack_require__(622)); -function prepareExistingDirectory(git, repositoryPath, repositoryUrl, clean) { +function prepareExistingDirectory(git, repositoryPath, repositoryUrl, clean, ref) { return __awaiter(this, void 0, void 0, function* () { assert.ok(repositoryPath, 'Expected repositoryPath to be defined'); assert.ok(repositoryUrl, 'Expected repositoryUrl to be defined'); @@ -7402,10 +7615,24 @@ function prepareExistingDirectory(git, repositoryPath, repositoryUrl, clean) { for (const branch of branches) { yield git.branchDelete(false, branch); } - // Remove all refs/remotes/origin/* to avoid conflicts - branches = yield git.branchList(true); - for (const branch of branches) { - yield git.branchDelete(true, branch); + // Remove any conflicting refs/remotes/origin/* + // Example 1: Consider ref is refs/heads/foo and previously fetched refs/remotes/origin/foo/bar + // Example 2: Consider ref is refs/heads/foo/bar and previously fetched refs/remotes/origin/foo + if (ref) { + ref = ref.startsWith('refs/') ? ref : `refs/heads/${ref}`; + if (ref.startsWith('refs/heads/')) { + const upperName1 = ref.toUpperCase().substr('REFS/HEADS/'.length); + const upperName1Slash = `${upperName1}/`; + branches = yield git.branchList(true); + for (const branch of branches) { + const upperName2 = branch.substr('origin/'.length).toUpperCase(); + const upperName2Slash = `${upperName2}/`; + if (upperName1.startsWith(upperName2Slash) || + upperName2.startsWith(upperName1Slash)) { + yield git.branchDelete(true, branch); + } + } + } } core.endGroup(); // Clean @@ -9336,6 +9563,11 @@ const v4_1 = __importDefault(__webpack_require__(826)); const IS_WINDOWS = process.platform === 'win32'; function downloadRepository(authToken, owner, repo, ref, commit, repositoryPath) { return __awaiter(this, void 0, void 0, function* () { + // Determine the default branch + if (!ref && !commit) { + core.info('Determining the default branch'); + ref = yield getDefaultBranch(authToken, owner, repo); + } // Download the archive let archiveData = yield retryHelper.execute(() => __awaiter(this, void 0, void 0, function* () { core.info('Downloading the archive'); @@ -9380,6 +9612,42 @@ function downloadRepository(authToken, owner, repo, ref, commit, repositoryPath) }); } exports.downloadRepository = downloadRepository; +/** + * Looks up the default branch name + */ +function getDefaultBranch(authToken, owner, repo) { + return __awaiter(this, void 0, void 0, function* () { + return yield retryHelper.execute(() => __awaiter(this, void 0, void 0, function* () { + core.info('Retrieving the default branch name'); + const octokit = new github.GitHub(authToken); + let result; + try { + // Get the default branch from the repo info + const response = yield octokit.repos.get({ owner, repo }); + result = response.data.default_branch; + assert.ok(result, 'default_branch cannot be empty'); + } + catch (err) { + // Handle .wiki repo + if (err['status'] === 404 && repo.toUpperCase().endsWith('.WIKI')) { + result = 'master'; + } + // Otherwise error + else { + throw err; + } + } + // Print the default branch + core.info(`Default branch '${result}'`); + // Prefix with 'refs/heads' + if (!result.startsWith('refs/')) { + result = `refs/heads/${result}`; + } + return result; + })); + }); +} +exports.getDefaultBranch = getDefaultBranch; function downloadArchive(authToken, owner, repo, ref, commit) { return __awaiter(this, void 0, void 0, function* () { const octokit = new github.GitHub(authToken); @@ -14282,9 +14550,6 @@ function getInputs() { result.ref = `refs/heads/${result.ref}`; } } - if (!result.ref && !result.commit) { - result.ref = 'refs/heads/master'; - } } // SHA? else if (result.ref.match(/^[0-9a-fA-F]{40}$/)) { @@ -14319,7 +14584,7 @@ function getInputs() { core.debug(`submodules = ${result.submodules}`); core.debug(`recursive submodules = ${result.nestedSubmodules}`); // Auth token - result.authToken = core.getInput('token'); + result.authToken = core.getInput('token', { required: true }); // SSH result.sshKey = core.getInput('ssh-key'); result.sshKnownHosts = core.getInput('ssh-known-hosts'); diff --git a/package.json b/package.json index 7b943ad..f413218 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "main": "lib/main.js", "scripts": { "build": "tsc && ncc build && node lib/misc/generate-docs.js", - "format": "prettier --write **/*.ts", - "format-check": "prettier --check **/*.ts", + "format": "prettier --write '**/*.ts'", + "format-check": "prettier --check '**/*.ts'", "lint": "eslint src/**/*.ts", "test": "jest" }, diff --git a/src/git-command-manager.ts b/src/git-command-manager.ts index 4cbfe4a..8bf3aa1 100644 --- a/src/git-command-manager.ts +++ b/src/git-command-manager.ts @@ -3,6 +3,7 @@ import * as exec from '@actions/exec' import * as fshelper from './fs-helper' import * as io from '@actions/io' import * as path from 'path' +import * as refHelper from './ref-helper' import * as regexpHelper from './regexp-helper' import * as retryHelper from './retry-helper' import {GitVersion} from './git-version' @@ -23,16 +24,19 @@ export interface IGitCommandManager { globalConfig?: boolean ): Promise configExists(configKey: string, globalConfig?: boolean): Promise - fetch(fetchDepth: number, refSpec: string[]): Promise + fetch(refSpec: string[], fetchDepth?: number): Promise + getDefaultBranch(repositoryUrl: string): Promise getWorkingDirectory(): string init(): Promise isDetached(): Promise lfsFetch(ref: string): Promise lfsInstall(): Promise - log1(): Promise + log1(): Promise remoteAdd(remoteName: string, remoteUrl: string): Promise removeEnvironmentVariable(name: string): void + revParse(ref: string): Promise setEnvironmentVariable(name: string, value: string): void + shaExists(sha: string): Promise submoduleForeach(command: string, recursive: boolean): Promise submoduleSync(recursive: boolean): Promise submoduleUpdate(fetchDepth: number, recursive: boolean): Promise @@ -164,17 +168,14 @@ class GitCommandManager { return output.exitCode === 0 } - async fetch(fetchDepth: number, refSpec: string[]): Promise { - const args = [ - '-c', - 'protocol.version=2', - 'fetch', - '--no-tags', - '--prune', - '--progress', - '--no-recurse-submodules' - ] - if (fetchDepth > 0) { + async fetch(refSpec: string[], fetchDepth?: number): Promise { + const args = ['-c', 'protocol.version=2', 'fetch'] + if (!refSpec.some(x => x === refHelper.tagsRefSpec)) { + args.push('--no-tags') + } + + args.push('--prune', '--progress', '--no-recurse-submodules') + if (fetchDepth && fetchDepth > 0) { args.push(`--depth=${fetchDepth}`) } else if ( fshelper.fileExistsSync( @@ -195,6 +196,34 @@ class GitCommandManager { }) } + async getDefaultBranch(repositoryUrl: string): Promise { + let output: GitOutput | undefined + await retryHelper.execute(async () => { + output = await this.execGit([ + 'ls-remote', + '--quiet', + '--exit-code', + '--symref', + repositoryUrl, + 'HEAD' + ]) + }) + + if (output) { + // Satisfy compiler, will always be set + for (let line of output.stdout.trim().split('\n')) { + line = line.trim() + if (line.startsWith('ref:') || line.endsWith('HEAD')) { + return line + .substr('ref:'.length, line.length - 'ref:'.length - 'HEAD'.length) + .trim() + } + } + } + + throw new Error('Unexpected output when retrieving default branch') + } + getWorkingDirectory(): string { return this.workingDirectory } @@ -225,8 +254,9 @@ class GitCommandManager { await this.execGit(['lfs', 'install', '--local']) } - async log1(): Promise { - await this.execGit(['log', '-1']) + async log1(): Promise { + const output = await this.execGit(['log', '-1']) + return output.stdout } async remoteAdd(remoteName: string, remoteUrl: string): Promise { @@ -237,10 +267,27 @@ class GitCommandManager { delete this.gitEnv[name] } + /** + * Resolves a ref to a SHA. For a branch or lightweight tag, the commit SHA is returned. + * For an annotated tag, the tag SHA is returned. + * @param {string} ref For example: 'refs/heads/master' or '/refs/tags/v1' + * @returns {Promise} + */ + async revParse(ref: string): Promise { + const output = await this.execGit(['rev-parse', ref]) + return output.stdout.trim() + } + setEnvironmentVariable(name: string, value: string): void { this.gitEnv[name] = value } + async shaExists(sha: string): Promise { + const args = ['rev-parse', '--verify', '--quiet', `${sha}^{object}`] + const output = await this.execGit(args, true) + return output.exitCode === 0 + } + async submoduleForeach(command: string, recursive: boolean): Promise { const args = ['submodule', 'foreach'] if (recursive) { diff --git a/src/git-directory-helper.ts b/src/git-directory-helper.ts index 3866866..e792190 100644 --- a/src/git-directory-helper.ts +++ b/src/git-directory-helper.ts @@ -5,13 +5,13 @@ import * as fsHelper from './fs-helper' import * as io from '@actions/io' import * as path from 'path' import {IGitCommandManager} from './git-command-manager' -import {IGitSourceSettings} from './git-source-settings' export async function prepareExistingDirectory( git: IGitCommandManager | undefined, repositoryPath: string, repositoryUrl: string, - clean: boolean + clean: boolean, + ref: string ): Promise { assert.ok(repositoryPath, 'Expected repositoryPath to be defined') assert.ok(repositoryUrl, 'Expected repositoryUrl to be defined') @@ -56,10 +56,26 @@ export async function prepareExistingDirectory( await git.branchDelete(false, branch) } - // Remove all refs/remotes/origin/* to avoid conflicts - branches = await git.branchList(true) - for (const branch of branches) { - await git.branchDelete(true, branch) + // Remove any conflicting refs/remotes/origin/* + // Example 1: Consider ref is refs/heads/foo and previously fetched refs/remotes/origin/foo/bar + // Example 2: Consider ref is refs/heads/foo/bar and previously fetched refs/remotes/origin/foo + if (ref) { + ref = ref.startsWith('refs/') ? ref : `refs/heads/${ref}` + if (ref.startsWith('refs/heads/')) { + const upperName1 = ref.toUpperCase().substr('REFS/HEADS/'.length) + const upperName1Slash = `${upperName1}/` + branches = await git.branchList(true) + for (const branch of branches) { + const upperName2 = branch.substr('origin/'.length).toUpperCase() + const upperName2Slash = `${upperName2}/` + if ( + upperName1.startsWith(upperName2Slash) || + upperName2.startsWith(upperName1Slash) + ) { + await git.branchDelete(true, branch) + } + } + } } core.endGroup() diff --git a/src/git-source-provider.ts b/src/git-source-provider.ts index 6855288..366ff33 100644 --- a/src/git-source-provider.ts +++ b/src/git-source-provider.ts @@ -42,7 +42,8 @@ export async function getSource(settings: IGitSourceSettings): Promise { git, settings.repositoryPath, repositoryUrl, - settings.clean + settings.clean, + settings.ref ) } @@ -102,6 +103,21 @@ export async function getSource(settings: IGitSourceSettings): Promise { await authHelper.configureAuth() core.endGroup() + // Determine the default branch + if (!settings.ref && !settings.commit) { + core.startGroup('Determining the default branch') + if (settings.sshKey) { + settings.ref = await git.getDefaultBranch(repositoryUrl) + } else { + settings.ref = await githubApiHelper.getDefaultBranch( + settings.authToken, + settings.repositoryOwner, + settings.repositoryName + ) + } + core.endGroup() + } + // LFS install if (settings.lfs) { await git.lfsInstall() @@ -109,8 +125,24 @@ export async function getSource(settings: IGitSourceSettings): Promise { // Fetch core.startGroup('Fetching the repository') - const refSpec = refHelper.getRefSpec(settings.ref, settings.commit) - await git.fetch(settings.fetchDepth, refSpec) + if (settings.fetchDepth <= 0) { + // Fetch all branches and tags + let refSpec = refHelper.getRefSpecForAllHistory( + settings.ref, + settings.commit + ) + await git.fetch(refSpec) + + // When all history is fetched, the ref we're interested in may have moved to a different + // commit (push or force push). If so, fetch again with a targeted refspec. + if (!(await refHelper.testRef(git, settings.ref, settings.commit))) { + refSpec = refHelper.getRefSpec(settings.ref, settings.commit) + await git.fetch(refSpec) + } + } else { + const refSpec = refHelper.getRefSpec(settings.ref, settings.commit) + await git.fetch(refSpec, settings.fetchDepth) + } core.endGroup() // Checkout info @@ -170,7 +202,17 @@ export async function getSource(settings: IGitSourceSettings): Promise { } // Dump some info about the checked out commit - await git.log1() + const commitInfo = await git.log1() + + // Check for incorrect pull request merge commit + await refHelper.checkCommitInfo( + settings.authToken, + commitInfo, + settings.repositoryOwner, + settings.repositoryName, + settings.ref, + settings.commit + ) } finally { // Remove auth if (!settings.persistCredentials) { diff --git a/src/github-api-helper.ts b/src/github-api-helper.ts index e559c45..8bbcf2d 100644 --- a/src/github-api-helper.ts +++ b/src/github-api-helper.ts @@ -19,6 +19,12 @@ export async function downloadRepository( commit: string, repositoryPath: string ): Promise { + // Determine the default branch + if (!ref && !commit) { + core.info('Determining the default branch') + ref = await getDefaultBranch(authToken, owner, repo) + } + // Download the archive let archiveData = await retryHelper.execute(async () => { core.info('Downloading the archive') @@ -67,6 +73,46 @@ export async function downloadRepository( io.rmRF(extractPath) } +/** + * Looks up the default branch name + */ +export async function getDefaultBranch( + authToken: string, + owner: string, + repo: string +): Promise { + return await retryHelper.execute(async () => { + core.info('Retrieving the default branch name') + const octokit = new github.GitHub(authToken) + let result: string + try { + // Get the default branch from the repo info + const response = await octokit.repos.get({owner, repo}) + result = response.data.default_branch + assert.ok(result, 'default_branch cannot be empty') + } catch (err) { + // Handle .wiki repo + if (err['status'] === 404 && repo.toUpperCase().endsWith('.WIKI')) { + result = 'master' + } + // Otherwise error + else { + throw err + } + } + + // Print the default branch + core.info(`Default branch '${result}'`) + + // Prefix with 'refs/heads' + if (!result.startsWith('refs/')) { + result = `refs/heads/${result}` + } + + return result + }) +} + async function downloadArchive( authToken: string, owner: string, diff --git a/src/input-helper.ts b/src/input-helper.ts index 11a1ab6..eabb9e0 100644 --- a/src/input-helper.ts +++ b/src/input-helper.ts @@ -68,10 +68,6 @@ export function getInputs(): IGitSourceSettings { result.ref = `refs/heads/${result.ref}` } } - - if (!result.ref && !result.commit) { - result.ref = 'refs/heads/master' - } } // SHA? else if (result.ref.match(/^[0-9a-fA-F]{40}$/)) { @@ -110,7 +106,7 @@ export function getInputs(): IGitSourceSettings { core.debug(`recursive submodules = ${result.nestedSubmodules}`) // Auth token - result.authToken = core.getInput('token') + result.authToken = core.getInput('token', {required: true}) // SSH result.sshKey = core.getInput('ssh-key') diff --git a/src/ref-helper.ts b/src/ref-helper.ts index ff256af..381fa60 100644 --- a/src/ref-helper.ts +++ b/src/ref-helper.ts @@ -1,4 +1,9 @@ +import {URL} from 'url' import {IGitCommandManager} from './git-command-manager' +import * as core from '@actions/core' +import * as github from '@actions/github' + +export const tagsRefSpec = '+refs/tags/*:refs/tags/*' export interface ICheckoutInfo { ref: string @@ -57,6 +62,16 @@ export async function getCheckoutInfo( return result } +export function getRefSpecForAllHistory(ref: string, commit: string): string[] { + const result = ['+refs/heads/*:refs/remotes/origin/*', tagsRefSpec] + if (ref && ref.toUpperCase().startsWith('REFS/PULL/')) { + const branch = ref.substring('refs/pull/'.length) + result.push(`+${commit || ref}:refs/remotes/pull/${branch}`) + } + + return result +} + export function getRefSpec(ref: string, commit: string): string[] { if (!ref && !commit) { throw new Error('Args ref and commit cannot both be empty') @@ -107,3 +122,162 @@ export function getRefSpec(ref: string, commit: string): string[] { return [`+${ref}:${ref}`] } } + +/** + * Tests whether the initial fetch created the ref at the expected commit + */ +export async function testRef( + git: IGitCommandManager, + ref: string, + commit: string +): Promise { + if (!git) { + throw new Error('Arg git cannot be empty') + } + + if (!ref && !commit) { + throw new Error('Args ref and commit cannot both be empty') + } + + // No SHA? Nothing to test + if (!commit) { + return true + } + // SHA only? + else if (!ref) { + return await git.shaExists(commit) + } + + const upperRef = ref.toUpperCase() + + // refs/heads/ + if (upperRef.startsWith('REFS/HEADS/')) { + const branch = ref.substring('refs/heads/'.length) + return ( + (await git.branchExists(true, `origin/${branch}`)) && + commit === (await git.revParse(`refs/remotes/origin/${branch}`)) + ) + } + // refs/pull/ + else if (upperRef.startsWith('REFS/PULL/')) { + // Assume matches because fetched using the commit + return true + } + // refs/tags/ + else if (upperRef.startsWith('REFS/TAGS/')) { + const tagName = ref.substring('refs/tags/'.length) + return ( + (await git.tagExists(tagName)) && commit === (await git.revParse(ref)) + ) + } + // Unexpected + else { + core.debug(`Unexpected ref format '${ref}' when testing ref info`) + return true + } +} + +export async function checkCommitInfo( + token: string, + commitInfo: string, + repositoryOwner: string, + repositoryName: string, + ref: string, + commit: string +): Promise { + try { + // GHES? + if (isGhes()) { + return + } + + // Auth token? + if (!token) { + return + } + + // Public PR synchronize, for workflow repo? + if ( + fromPayload('repository.private') !== false || + github.context.eventName !== 'pull_request' || + fromPayload('action') !== 'synchronize' || + repositoryOwner !== github.context.repo.owner || + repositoryName !== github.context.repo.repo || + ref !== github.context.ref || + !ref.startsWith('refs/pull/') || + commit !== github.context.sha + ) { + return + } + + // Head SHA + const expectedHeadSha = fromPayload('after') + if (!expectedHeadSha) { + core.debug('Unable to determine head sha') + return + } + + // Base SHA + const expectedBaseSha = fromPayload('pull_request.base.sha') + if (!expectedBaseSha) { + core.debug('Unable to determine base sha') + return + } + + // Expected message? + const expectedMessage = `Merge ${expectedHeadSha} into ${expectedBaseSha}` + if (commitInfo.indexOf(expectedMessage) >= 0) { + return + } + + // Extract details from message + const match = commitInfo.match(/Merge ([0-9a-f]{40}) into ([0-9a-f]{40})/) + if (!match) { + core.debug('Unexpected message format') + return + } + + // Post telemetry + const actualHeadSha = match[1] + if (actualHeadSha !== expectedHeadSha) { + core.debug( + `Expected head sha ${expectedHeadSha}; actual head sha ${actualHeadSha}` + ) + const octokit = new github.GitHub(token, { + userAgent: `actions-checkout-tracepoint/1.0 (code=STALE_MERGE;owner=${repositoryOwner};repo=${repositoryName};pr=${fromPayload( + 'number' + )};run_id=${ + process.env['GITHUB_RUN_ID'] + };expected_head_sha=${expectedHeadSha};actual_head_sha=${actualHeadSha})` + }) + await octokit.repos.get({owner: repositoryOwner, repo: repositoryName}) + } + } catch (err) { + core.debug(`Error when validating commit info: ${err.stack}`) + } +} + +function fromPayload(path: string): any { + return select(github.context.payload, path) +} + +function select(obj: any, path: string): any { + if (!obj) { + return undefined + } + + const i = path.indexOf('.') + if (i < 0) { + return obj[path] + } + + const key = path.substr(0, i) + return select(obj[key], path.substr(i + 1)) +} + +function isGhes(): boolean { + const ghUrl = new URL( + process.env['GITHUB_SERVER_URL'] || 'https://github.com' + ) + return ghUrl.hostname.toUpperCase() !== 'GITHUB.COM' +}