Skip to content

Commit 166d5fe

Browse files
authored
Merge pull request #433 from github/deleted-branch-fixes
Fix non-existent branch issues on closed PRs edge case
2 parents 43454e7 + 39a14ec commit 166d5fe

File tree

4 files changed

+270
-1
lines changed

4 files changed

+270
-1
lines changed

__tests__/functions/prechecks.test.js

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3674,3 +3674,210 @@ test('runs prechecks and finds the PR is NOT behind the stable branch (HAS_HOOKS
36743674
'beefdead'
36753675
)
36763676
})
3677+
3678+
// Tests for branch existence checks
3679+
class NotFoundError extends Error {
3680+
constructor(message) {
3681+
super(message)
3682+
this.status = 404
3683+
}
3684+
}
3685+
3686+
class UnexpectedError extends Error {
3687+
constructor(message) {
3688+
super(message)
3689+
this.status = 500
3690+
}
3691+
}
3692+
3693+
test('fails prechecks when the branch does not exist (deleted branch)', async () => {
3694+
// Mock getBranch to throw a 404 error for the PR branch check
3695+
octokit.rest.repos.getBranch = vi
3696+
.fn()
3697+
// First call: stable branch check (succeeds)
3698+
.mockReturnValueOnce({
3699+
data: {
3700+
commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}},
3701+
name: 'main'
3702+
},
3703+
status: 200
3704+
})
3705+
// Second call: base branch check (succeeds)
3706+
.mockReturnValueOnce({
3707+
data: {commit: {sha: 'deadbeef'}, name: 'main'},
3708+
status: 200
3709+
})
3710+
// Third call: PR branch check (fails with 404)
3711+
.mockRejectedValueOnce(new NotFoundError('Reference does not exist'))
3712+
3713+
const result = await prechecks(context, octokit, data)
3714+
3715+
expect(result.status).toBe(false)
3716+
expect(result.message).toContain('Cannot proceed with deployment')
3717+
expect(result.message).toContain('ref: `test-ref`')
3718+
expect(result.message).toContain(
3719+
'The branch for this pull request no longer exists'
3720+
)
3721+
expect(warningMock).toHaveBeenCalledWith('branch does not exist: test-ref')
3722+
})
3723+
3724+
test('passes prechecks when branch exists (normal deployment)', async () => {
3725+
// Mock getBranch to succeed for all calls
3726+
octokit.rest.repos.getBranch = vi
3727+
.fn()
3728+
// First call: stable branch check
3729+
.mockReturnValueOnce({
3730+
data: {
3731+
commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}},
3732+
name: 'main'
3733+
},
3734+
status: 200
3735+
})
3736+
// Second call: base branch check
3737+
.mockReturnValueOnce({
3738+
data: {commit: {sha: 'deadbeef'}, name: 'main'},
3739+
status: 200
3740+
})
3741+
// Third call: PR branch check (succeeds)
3742+
.mockReturnValueOnce({
3743+
data: {commit: {sha: 'abc123'}, name: 'test-ref'},
3744+
status: 200
3745+
})
3746+
3747+
const result = await prechecks(context, octokit, data)
3748+
3749+
expect(result.status).toBe(true)
3750+
expect(result.ref).toBe('test-ref')
3751+
expect(debugMock).toHaveBeenCalledWith('checking if branch exists: test-ref')
3752+
expect(infoMock).toHaveBeenCalledWith('✅ branch exists: test-ref')
3753+
})
3754+
3755+
test('skips branch existence check when deploying to stable branch', async () => {
3756+
data.environmentObj.stable_branch_used = true
3757+
3758+
// Mock getBranch - should only be called twice (not three times)
3759+
octokit.rest.repos.getBranch = vi
3760+
.fn()
3761+
.mockReturnValueOnce({
3762+
data: {
3763+
commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}},
3764+
name: 'main'
3765+
},
3766+
status: 200
3767+
})
3768+
.mockReturnValueOnce({
3769+
data: {commit: {sha: 'deadbeef'}, name: 'main'},
3770+
status: 200
3771+
})
3772+
3773+
const result = await prechecks(context, octokit, data)
3774+
3775+
expect(result.status).toBe(true)
3776+
// Verify the branch existence check was skipped (only 2 getBranch calls, not 3)
3777+
expect(octokit.rest.repos.getBranch).toHaveBeenCalledTimes(2)
3778+
expect(debugMock).not.toHaveBeenCalledWith(
3779+
'checking if branch exists: test-ref'
3780+
)
3781+
})
3782+
3783+
test('skips branch existence check when deploying an exact SHA', async () => {
3784+
data.environmentObj.sha = 'abc123def456'
3785+
data.inputs.allow_sha_deployments = true
3786+
3787+
octokit.rest.repos.getBranch = vi
3788+
.fn()
3789+
.mockReturnValueOnce({
3790+
data: {
3791+
commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}},
3792+
name: 'main'
3793+
},
3794+
status: 200
3795+
})
3796+
.mockReturnValueOnce({
3797+
data: {commit: {sha: 'deadbeef'}, name: 'main'},
3798+
status: 200
3799+
})
3800+
3801+
const result = await prechecks(context, octokit, data)
3802+
3803+
expect(result.status).toBe(true)
3804+
// Verify the branch existence check was skipped
3805+
expect(octokit.rest.repos.getBranch).toHaveBeenCalledTimes(2)
3806+
expect(debugMock).not.toHaveBeenCalledWith(
3807+
'checking if branch exists: test-ref'
3808+
)
3809+
})
3810+
3811+
test('skips branch existence check when PR is from a fork', async () => {
3812+
// Mock the PR as a fork
3813+
octokit.rest.pulls.get = vi.fn().mockReturnValue({
3814+
data: {
3815+
head: {
3816+
ref: 'test-ref',
3817+
sha: 'abc123',
3818+
repo: {
3819+
fork: true
3820+
},
3821+
label: 'fork:test-ref'
3822+
},
3823+
base: {
3824+
ref: 'main'
3825+
},
3826+
draft: false
3827+
},
3828+
status: 200
3829+
})
3830+
3831+
octokit.rest.repos.getBranch = vi
3832+
.fn()
3833+
.mockReturnValueOnce({
3834+
data: {
3835+
commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}},
3836+
name: 'main'
3837+
},
3838+
status: 200
3839+
})
3840+
.mockReturnValueOnce({
3841+
data: {commit: {sha: 'deadbeef'}, name: 'main'},
3842+
status: 200
3843+
})
3844+
3845+
const result = await prechecks(context, octokit, data)
3846+
3847+
expect(result.status).toBe(true)
3848+
expect(result.isFork).toBe(true)
3849+
// Verify the branch existence check was skipped for forks
3850+
expect(octokit.rest.repos.getBranch).toHaveBeenCalledTimes(2)
3851+
expect(debugMock).not.toHaveBeenCalledWith(
3852+
'checking if branch exists: abc123'
3853+
)
3854+
})
3855+
3856+
test('fails prechecks when branch check encounters unexpected error', async () => {
3857+
// Mock getBranch to throw a non-404 error
3858+
octokit.rest.repos.getBranch = vi
3859+
.fn()
3860+
.mockReturnValueOnce({
3861+
data: {
3862+
commit: {sha: 'deadbeef', commit: {tree: {sha: 'beefdead'}}},
3863+
name: 'main'
3864+
},
3865+
status: 200
3866+
})
3867+
.mockReturnValueOnce({
3868+
data: {commit: {sha: 'deadbeef'}, name: 'main'},
3869+
status: 200
3870+
})
3871+
.mockRejectedValueOnce(new UnexpectedError('Internal server error'))
3872+
3873+
const result = await prechecks(context, octokit, data)
3874+
3875+
// Should fail and not continue
3876+
expect(result.status).toBe(false)
3877+
expect(result.message).toContain('Cannot proceed with deployment')
3878+
expect(result.message).toContain('ref: `test-ref`')
3879+
expect(result.message).toContain(
3880+
'An unexpected error occurred while checking if the branch exists'
3881+
)
3882+
expect(result.message).toContain('Internal server error')
3883+
})

dist/index.js

Lines changed: 31 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/functions/prechecks.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,37 @@ export async function prechecks(context, octokit, data) {
381381
core.saveState('review_decision', reviewDecision)
382382
core.saveState('approved_reviews_count', approvedReviewsCount)
383383

384+
// Check if the branch exists before proceeding with deployment
385+
// Skip this check if:
386+
// 1. We're deploying to the stable branch (e.g., `.deploy main`)
387+
// 2. We're deploying an exact SHA (allow_sha_deployments is enabled and a SHA was provided)
388+
// 3. The PR is from a fork (we use SHA for forks, not branch names)
389+
if (
390+
data.environmentObj.stable_branch_used !== true &&
391+
data.environmentObj.sha === null &&
392+
isFork === false
393+
) {
394+
core.debug(`checking if branch exists: ${ref}`)
395+
try {
396+
await octokit.rest.repos.getBranch({
397+
...context.repo,
398+
branch: ref,
399+
headers: API_HEADERS
400+
})
401+
core.info(`✅ branch exists: ${ref}`)
402+
} catch (error) {
403+
if (error.status === 404) {
404+
message = `### ⚠️ Cannot proceed with deployment\n\n- ref: \`${ref}\`\n\nThe branch for this pull request no longer exists. This can happen if the branch was deleted after the PR was merged or closed. If you need to deploy, you can:\n- Use the stable branch deployment (e.g., \`${data.inputs.trigger} ${data.inputs.stable_branch}\`)\n- Use an exact SHA deployment if enabled (e.g., \`${data.inputs.trigger} ${sha}\`)\n\n> If you are running this command on a closed pull request, you can also try reopening the pull request to restore the branch for a deployment.`
405+
core.warning(`branch does not exist: ${ref}`)
406+
return {message: message, status: false}
407+
}
408+
// If it's not a 404 error, it's unexpected - hard stop
409+
message = `### ⚠️ Cannot proceed with deployment\n\n- ref: \`${ref}\`\n\n> An unexpected error occurred while checking if the branch exists: \`${error.message}\``
410+
core.error(`unexpected error checking if branch exists: ${error.message}`)
411+
return {message: message, status: false}
412+
}
413+
}
414+
384415
// Always allow deployments to the "stable" branch regardless of CI checks or PR review
385416
if (data.environmentObj.stable_branch_used === true) {
386417
message = `✅ deployment to the ${COLORS.highlight}stable${COLORS.reset} branch requested`

0 commit comments

Comments
 (0)