diff --git a/packages/github/RELEASES.md b/packages/github/RELEASES.md index d56d82d49c..487e12378c 100644 --- a/packages/github/RELEASES.md +++ b/packages/github/RELEASES.md @@ -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 diff --git a/packages/github/__tests__/orchestration.test.ts b/packages/github/__tests__/orchestration.test.ts new file mode 100644 index 0000000000..044fd45836 --- /dev/null +++ b/packages/github/__tests__/orchestration.test.ts @@ -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' + ) + }) + }) +}) diff --git a/packages/github/src/internal/utils.ts b/packages/github/src/internal/utils.ts index c97db1850e..03c5822a6a 100644 --- a/packages/github/src/internal/utils.ts +++ b/packages/github/src/internal/utils.ts @@ -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) { + 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 +} diff --git a/packages/github/src/utils.ts b/packages/github/src/utils.ts index e06c4f98d0..d783278d84 100644 --- a/packages/github/src/utils.ts +++ b/packages/github/src/utils.ts @@ -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. * @@ -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 }