Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/commands/ci/handle-ci.mts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { debugDir, debugFn } from '@socketsecurity/registry/lib/debug'
import { envAsString } from '@socketsecurity/registry/lib/env'
import { logger } from '@socketsecurity/registry/lib/logger'

import { getDefaultOrgSlug } from './fetch-default-org-slug.mts'
Expand All @@ -11,6 +12,19 @@ import {
import { serializeResultJson } from '../../utils/serialize-result-json.mts'
import { handleCreateNewScan } from '../scan/handle-create-new-scan.mts'

/**
* Derive the pull request number from the CI environment. GitHub Actions
* pull_request events check out `refs/pull/<n>/merge`, so the number is
* recoverable from GITHUB_REF; returns 0 outside a PR run (the API omits
* `pull_request` for falsy values).
*/
export function detectCiPullRequestNumber(): number {
const match = /^refs\/pull\/(\d+)\//.exec(
envAsString(process.env['GITHUB_REF']),
)
return match ? Number(match[1]) : 0
}

export async function handleCi(autoManifest: boolean): Promise<void> {
debugFn('notice', 'Starting CI scan')
debugDir('inspect', { autoManifest })
Expand Down Expand Up @@ -49,7 +63,7 @@ export async function handleCi(autoManifest: boolean): Promise<void> {
outputKind: 'json',
// When 'pendingHead' is true, it requires 'branchName' set and 'tmp' false.
pendingHead: true,
pullRequest: 0,
pullRequest: detectCiPullRequestNumber(),
reach: {
dynamicSbomInference: false,
excludePaths: [],
Expand Down
49 changes: 49 additions & 0 deletions src/commands/ci/handle-ci.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'

import { detectCiPullRequestNumber } from './handle-ci.mts'

let originalGithubRef: string | undefined

describe('detectCiPullRequestNumber', () => {
beforeEach(() => {
originalGithubRef = process.env['GITHUB_REF']
delete process.env['GITHUB_REF']
})

afterEach(() => {
if (originalGithubRef === undefined) {
delete process.env['GITHUB_REF']
} else {
process.env['GITHUB_REF'] = originalGithubRef
}
})

it('derives the number from a pull request merge ref', () => {
process.env['GITHUB_REF'] = 'refs/pull/482/merge'
expect(detectCiPullRequestNumber()).toBe(482)
})

it('derives the number from a pull request head ref', () => {
process.env['GITHUB_REF'] = 'refs/pull/482/head'
expect(detectCiPullRequestNumber()).toBe(482)
})

it('returns 0 for a branch push', () => {
process.env['GITHUB_REF'] = 'refs/heads/feature-branch'
expect(detectCiPullRequestNumber()).toBe(0)
})

it('returns 0 for a tag push', () => {
process.env['GITHUB_REF'] = 'refs/tags/v1.2.3'
expect(detectCiPullRequestNumber()).toBe(0)
})

it('returns 0 when GITHUB_REF is not a numbered pull ref', () => {
process.env['GITHUB_REF'] = 'refs/pull/not-a-number/merge'
expect(detectCiPullRequestNumber()).toBe(0)
})

it('returns 0 outside GitHub Actions', () => {
expect(detectCiPullRequestNumber()).toBe(0)
})
})
36 changes: 36 additions & 0 deletions src/utils/git.mts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@
* Repository Information:
* - detectDefaultBranch: Find default branch (main/master/develop/etc)
* - getBaseBranch: Determine base branch (respects GitHub Actions env)
* - getCiBranch: Branch name a GitHub Actions run is on
* - getRepoInfo: Extract owner/repo from git remote URL
* - gitBranch: Get current branch or commit hash
*/

import { debugDir, debugFn, isDebug } from '@socketsecurity/registry/lib/debug'
import { envAsString } from '@socketsecurity/registry/lib/env'
import { normalizePath } from '@socketsecurity/registry/lib/path'
import { isSpawnError, spawn } from '@socketsecurity/registry/lib/spawn'

Expand Down Expand Up @@ -77,6 +79,32 @@ export async function getBaseBranch(cwd = process.cwd()): Promise<string> {
return 'main'
}

/**
* The branch a GitHub Actions workflow run is on, or undefined when the
* environment does not identify one. Read straight from process.env because
* GITHUB_HEAD_REF is not part of the constants.ENV snapshot.
*/
export function getCiBranch(): string | undefined {
// The head branch of a pull request, only set for pull_request and
// pull_request_target events. Checked first because GITHUB_REF_NAME is
// '<pr_number>/merge' on those events, which is not a branch name.
// https://docs.github.com/en/actions/reference/workflows-and-actions/variables#default-environment-variables
const githubHeadRef = envAsString(process.env['GITHUB_HEAD_REF'])
if (githubHeadRef) {
return githubHeadRef
}
// The pushed ref. GITHUB_REF_TYPE tells branches and tags apart, and a tag
// is not a branch name.
const githubRefName = envAsString(process.env['GITHUB_REF_NAME'])
if (
envAsString(process.env['GITHUB_REF_TYPE']) === 'branch' &&
githubRefName
) {
return githubRefName
}
return undefined
}

export type RepoInfo = {
owner: string
repo: string
Expand Down Expand Up @@ -133,6 +161,14 @@ export async function gitBranch(
// Expected in detached HEAD state, fallback to rev-parse.
debugDir('inspect', { message: 'In detached HEAD state', error: e })
}
// Detached HEAD is the normal state in CI checkouts (actions/checkout),
// where the commit-SHA fallback below would mislabel the scan's branch — a
// SHA can never match the repo's default branch, so scans vanish from the
// Main/PR tabs. Prefer the branch the CI run says it is on.
const ciBranch = getCiBranch()
if (ciBranch) {
return ciBranch
}
// Fallback to using rev-parse to get the short commit hash in a
// detached HEAD state.
try {
Expand Down
140 changes: 140 additions & 0 deletions src/utils/git.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'

import { afterEach, beforeEach, describe, expect, it } from 'vitest'

import { spawn } from '@socketsecurity/registry/lib/spawn'

import { getCiBranch, gitBranch } from './git.mts'

// GitHub Actions sets these in its own runs, so they have to be cleared for
// the tests to exercise anything other than the CI job they run inside.
const GITHUB_ENV_VARS = [
'GITHUB_HEAD_REF',
'GITHUB_REF_NAME',
'GITHUB_REF_TYPE',
]

const originalEnv = new Map<string, string | undefined>()

async function createTempRepo(): Promise<string> {
const repoPath = mkdtempSync(path.join(tmpdir(), 'socket-git-branch-'))
const options = { cwd: repoPath }
await spawn('git', ['init', '--initial-branch', 'feature-branch'], options)
await spawn('git', ['config', 'user.email', 'test@socket.dev'], options)
await spawn('git', ['config', 'user.name', 'Socket Test'], options)
await spawn('git', ['config', 'commit.gpgsign', 'false'], options)
writeFileSync(path.join(repoPath, 'README.md'), '# test\n')
await spawn('git', ['add', 'README.md'], options)
await spawn('git', ['commit', '-m', 'Initial commit'], options)
return repoPath
}

describe('getCiBranch', () => {
beforeEach(() => {
for (const name of GITHUB_ENV_VARS) {
originalEnv.set(name, process.env[name])
delete process.env[name]
}
})

afterEach(() => {
for (const name of GITHUB_ENV_VARS) {
const value = originalEnv.get(name)
if (value === undefined) {
delete process.env[name]
} else {
process.env[name] = value
}
}
originalEnv.clear()
})

it('returns the pull request head branch', () => {
process.env['GITHUB_HEAD_REF'] = 'feature/pr-branch'
expect(getCiBranch()).toBe('feature/pr-branch')
})

it('prefers the pull request head branch over the merge ref', () => {
process.env['GITHUB_HEAD_REF'] = 'feature/pr-branch'
// What GitHub Actions actually sets on a pull_request event.
process.env['GITHUB_REF_NAME'] = '123/merge'
process.env['GITHUB_REF_TYPE'] = 'branch'
expect(getCiBranch()).toBe('feature/pr-branch')
})

it('returns the pushed branch ref outside a pull request', () => {
process.env['GITHUB_REF_NAME'] = 'main'
process.env['GITHUB_REF_TYPE'] = 'branch'
expect(getCiBranch()).toBe('main')
})

it('ignores a tag ref', () => {
process.env['GITHUB_REF_NAME'] = 'v1.2.3'
process.env['GITHUB_REF_TYPE'] = 'tag'
expect(getCiBranch()).toBeUndefined()
})

it('returns undefined outside GitHub Actions', () => {
expect(getCiBranch()).toBeUndefined()
})
})

describe('gitBranch', () => {
let repoPath = ''

beforeEach(async () => {
for (const name of GITHUB_ENV_VARS) {
originalEnv.set(name, process.env[name])
delete process.env[name]
}
repoPath = await createTempRepo()
})

afterEach(() => {
for (const name of GITHUB_ENV_VARS) {
const value = originalEnv.get(name)
if (value === undefined) {
delete process.env[name]
} else {
process.env[name] = value
}
}
originalEnv.clear()
rmSync(repoPath, { force: true, recursive: true })
})

it('returns the checked out branch', async () => {
expect(await gitBranch(repoPath)).toBe('feature-branch')
})

it('falls back to the commit hash in a detached HEAD with no CI env', async () => {
await spawn('git', ['checkout', '--detach'], { cwd: repoPath })
const shortHash = (
await spawn('git', ['rev-parse', '--short', 'HEAD'], { cwd: repoPath })
).stdout
expect(await gitBranch(repoPath)).toBe(shortHash)
})

it('returns the pull request head branch in a detached HEAD', async () => {
await spawn('git', ['checkout', '--detach'], { cwd: repoPath })
process.env['GITHUB_HEAD_REF'] = 'feature/pr-branch'
process.env['GITHUB_REF_NAME'] = '123/merge'
process.env['GITHUB_REF_TYPE'] = 'branch'
expect(await gitBranch(repoPath)).toBe('feature/pr-branch')
})

it('returns the pushed branch ref in a detached HEAD', async () => {
await spawn('git', ['checkout', '--detach'], { cwd: repoPath })
process.env['GITHUB_REF_NAME'] = 'main'
process.env['GITHUB_REF_TYPE'] = 'branch'
expect(await gitBranch(repoPath)).toBe('main')
})

it('prefers the checked out branch over the CI env', async () => {
process.env['GITHUB_REF_NAME'] = 'main'
process.env['GITHUB_REF_TYPE'] = 'branch'
expect(await gitBranch(repoPath)).toBe('feature-branch')
})
})
Loading