Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
114 changes: 114 additions & 0 deletions packages/github/__tests__/orchestration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
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')
})
})

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_')
})
})
})
12 changes: 12 additions & 0 deletions packages/github/src/internal/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,15 @@ 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 ua = baseUserAgent ? `${baseUserAgent} ` : ''
return `${ua}actions_orchestration_id/${sanitizedId}`
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

will concat the string together cause invalid user-agent format?

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.

The concatenation should be fine — the User-Agent spec (RFC 9110 §10.1.5) is just space-separated product/version tokens, and actions_orchestration_id/<sanitizedId> fits that since the ID is sanitized to [a-z0-9_.-].

Octokit already concatenates the same way internally (${options.userAgent} ${userAgentTrail}), so the final header ends up looking like:

actions_orchestration_id/abc123 octokit-core.js/6.1.3 Node.js/20

Also worth noting this is the same approach used by @actions/http-client and @actions/attest already in the toolkit.

}
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