Skip to content
Merged
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
5 changes: 5 additions & 0 deletions packages/github/RELEASES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# @actions/github Releases

### 9.1.0

- Append `actions_orchestration_id` to user-agent when `ACTIONS_ORCHESTRATION_ID` environment variable is set
- Export `getUserAgentWithOrchestrationId` from `@actions/github/lib/utils` for downstream consumers

### 9.0.0

- **Breaking change**: Package is now ESM-only
Expand Down
130 changes: 130 additions & 0 deletions packages/github/__tests__/orchestration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import {getOctokitOptions, getUserAgentWithOrchestrationId} from '../src/utils'
import {getUserAgentWithOrchestrationId as internalGetUserAgentWithOrchestrationId} from '../src/internal/utils'

describe('orchestration ID support', () => {
let originalOrchId: string | undefined

beforeEach(() => {
originalOrchId = process.env['ACTIONS_ORCHESTRATION_ID']
delete process.env['ACTIONS_ORCHESTRATION_ID']
})

afterEach(() => {
if (originalOrchId !== undefined) {
process.env['ACTIONS_ORCHESTRATION_ID'] = originalOrchId
} else {
delete process.env['ACTIONS_ORCHESTRATION_ID']
}
})

describe('getUserAgentWithOrchestrationId', () => {
it('returns undefined when env var is not set and no base user agent', () => {
expect(getUserAgentWithOrchestrationId()).toBeUndefined()
})

it('returns base user agent unchanged when env var is not set', () => {
expect(getUserAgentWithOrchestrationId('my-app')).toBe('my-app')
})

it('returns orchestration ID without base when env var is set and no base', () => {
process.env['ACTIONS_ORCHESTRATION_ID'] = 'abc-123'
expect(getUserAgentWithOrchestrationId()).toBe(
'actions_orchestration_id/abc-123'
)
})

it('appends orchestration ID to base user agent', () => {
process.env['ACTIONS_ORCHESTRATION_ID'] = 'abc-123'
expect(getUserAgentWithOrchestrationId('my-app')).toBe(
'my-app actions_orchestration_id/abc-123'
)
})

it('sanitizes special characters in orchestration ID', () => {
process.env['ACTIONS_ORCHESTRATION_ID'] = 'id with spaces/and$pecial!'
expect(getUserAgentWithOrchestrationId('my-app')).toBe(
'my-app actions_orchestration_id/id_with_spaces_and_pecial_'
)
})

it('preserves allowed characters in orchestration ID', () => {
process.env['ACTIONS_ORCHESTRATION_ID'] =
'valid_id-with.allowed_chars.123'
expect(getUserAgentWithOrchestrationId()).toBe(
'actions_orchestration_id/valid_id-with.allowed_chars.123'
)
})

it('ignores whitespace-only orchestration ID', () => {
process.env['ACTIONS_ORCHESTRATION_ID'] = ' '
expect(getUserAgentWithOrchestrationId('my-app')).toBe('my-app')
})

it('does not duplicate orchestration ID if already present in base', () => {
process.env['ACTIONS_ORCHESTRATION_ID'] = 'abc-123'
const alreadyTagged = 'my-app actions_orchestration_id/abc-123'
expect(getUserAgentWithOrchestrationId(alreadyTagged)).toBe(alreadyTagged)
})
})

describe('public re-export', () => {
it('exports getUserAgentWithOrchestrationId from utils (public API)', () => {
expect(getUserAgentWithOrchestrationId).toBe(
internalGetUserAgentWithOrchestrationId
)
})
})

describe('getOctokitOptions', () => {
it('sets userAgent when ACTIONS_ORCHESTRATION_ID is set', () => {
process.env['ACTIONS_ORCHESTRATION_ID'] = 'test-orch-id'
const opts = getOctokitOptions('fake-token')
expect(opts.userAgent).toBe('actions_orchestration_id/test-orch-id')
})

it('does not set userAgent when ACTIONS_ORCHESTRATION_ID is not set', () => {
const opts = getOctokitOptions('fake-token')
expect(opts.userAgent).toBeUndefined()
})

it('preserves and appends to caller-provided userAgent', () => {
process.env['ACTIONS_ORCHESTRATION_ID'] = 'test-orch-id'
const opts = getOctokitOptions('fake-token', {
userAgent: 'custom-agent/1.0'
})
expect(opts.userAgent).toBe(
'custom-agent/1.0 actions_orchestration_id/test-orch-id'
)
})

it('leaves caller-provided userAgent intact when env var is not set', () => {
const opts = getOctokitOptions('fake-token', {
userAgent: 'custom-agent/1.0'
})
expect(opts.userAgent).toBe('custom-agent/1.0')
})

it('does not mutate the original options object', () => {
process.env['ACTIONS_ORCHESTRATION_ID'] = 'test-orch-id'
const original = {userAgent: 'original/1.0'}
getOctokitOptions('fake-token', original)
expect(original.userAgent).toBe('original/1.0')
})

it('sanitizes special characters through getOctokitOptions', () => {
process.env['ACTIONS_ORCHESTRATION_ID'] = 'bad chars here!'
const opts = getOctokitOptions('fake-token')
expect(opts.userAgent).toBe('actions_orchestration_id/bad_chars_here_')
})

it('does not duplicate orchestration ID when caller already applied it', () => {
process.env['ACTIONS_ORCHESTRATION_ID'] = 'test-orch-id'
const opts = getOctokitOptions('fake-token', {
userAgent: 'my-app actions_orchestration_id/test-orch-id'
})
expect(opts.userAgent).toBe(
'my-app actions_orchestration_id/test-orch-id'
)
})
})
})
14 changes: 14 additions & 0 deletions packages/github/src/internal/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,17 @@ export function getProxyFetch(destinationUrl): typeof fetch {
export function getApiBaseUrl(): string {
return process.env['GITHUB_API_URL'] || 'https://api.github.com'
}

export function getUserAgentWithOrchestrationId(
baseUserAgent?: string
): string | undefined {
const orchId = process.env['ACTIONS_ORCHESTRATION_ID']?.trim()
if (orchId) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we check if base user agent already contains the orchestration ID? Or is that unlikely?

const sanitizedId = orchId.replace(/[^a-z0-9_.-]/gi, '_')
const tag = `actions_orchestration_id/${sanitizedId}`
if (baseUserAgent?.includes(tag)) return baseUserAgent
const ua = baseUserAgent ? `${baseUserAgent} ` : ''
return `${ua}${tag}`
}
return baseUserAgent
}
10 changes: 10 additions & 0 deletions packages/github/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export const GitHub = Octokit.plugin(
paginateRest
).defaults(defaults)

export {getUserAgentWithOrchestrationId} from './internal/utils.js'

/**
* Convience function to correctly format Octokit Options to pass into the constructor.
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spelling: "Convience" should be "Convenience" in the JSDoc comment to avoid propagating the typo into generated docs/search results.

Suggested change
* Convience function to correctly format Octokit Options to pass into the constructor.
* Convenience function to correctly format Octokit Options to pass into the constructor.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was already there, not related to change, good thing to fix tho

*
Expand All @@ -41,5 +43,13 @@ export function getOctokitOptions(
opts.auth = auth
}

// Orchestration ID
const userAgent = Utils.getUserAgentWithOrchestrationId(
opts.userAgent as string | undefined
)
if (userAgent) {
opts.userAgent = userAgent
}

return opts
}
Loading