diff --git a/.env.example b/.env.example index ca6c599..a41d909 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,7 @@ ACTIONS_STEP_DEBUG=true ################################################################################ INPUT_LCOV-FILE-PATHS=coverage/lcov.info +# INPUT_REGION=eu # INPUT_FAIL-ON-ERROR=true ################################################################################ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffdd810..83fd21e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,12 +4,59 @@ on: push: {} jobs: - build-and-test: + format: + runs-on: open-source-releaser + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + cache: npm + + - run: npm ci + - run: npm run format:check + + lint: + runs-on: open-source-releaser + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + cache: npm + + - run: npm ci + - run: npm run lint + + test: + runs-on: open-source-releaser + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + cache: npm + + - run: npm ci + - run: npm test + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage + path: coverage/lcov.info + if-no-files-found: error + + test-integration: runs-on: open-source-releaser - permissions: - packages: write - contents: write - id-token: write steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: @@ -21,18 +68,49 @@ jobs: cache: npm - run: npm ci + - run: npm run test:integration - - name: Check formatting - run: npm run format:check + build: + runs-on: open-source-releaser + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - - name: Lint - run: npm run lint + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + cache: npm + + - run: npm ci + - run: npm run build + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dist + path: dist/ + if-no-files-found: error + + test-action: + needs: [test, build] + runs-on: open-source-releaser + permissions: + id-token: write + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - - name: Test - run: npm test + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: coverage + path: coverage/ - - name: Build - run: npm run build + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dist + path: dist/ - name: Test Aikido Upload Code Coverage action in workflow with OIDC uses: ./ diff --git a/README.dev.md b/README.dev.md index 7170fe3..e87aa5c 100644 --- a/README.dev.md +++ b/README.dev.md @@ -19,14 +19,15 @@ npm install ## npm scripts -| Script | Description | -| ---------------- | -------------------------------------------------- | -| `npm test` | Run unit tests with Jest | -| `npm run lint` | Lint `src/` and `__tests__/` with ESLint | -| `npm run format` | Format JavaScript files with Prettier | -| `npm run build` | Bundle `src/main.js` into `dist/index.js` with ncc | -| `npm run local` | Run the action locally via `@github/local-action` | -| `npm run all` | Format, lint, test, and build in one command | +| Script | Description | +| ------------------ | -------------------------------------------------- | +| `npm test` | Run unit and e2e tests with Jest | +| `npm run test:e2e` | Run e2e/integration tests only | +| `npm run lint` | Lint `src/` and `__tests__/` with ESLint | +| `npm run format` | Format JavaScript files with Prettier | +| `npm run build` | Bundle `src/main.js` into `dist/index.js` with ncc | +| `npm run local` | Run the action locally via `@github/local-action` | +| `npm run all` | Format, lint, test, and build in one command | Before opening a pull request, run the full check: @@ -57,6 +58,7 @@ GitHub Actions inputs are exposed as environment variables with an `INPUT_` pref | Variable | Required | Description | | ----------------------- | -------- | -------------------------------------------------- | | `INPUT_LCOV-FILE-PATHS` | yes | Path(s) to LCOV file(s), e.g. `coverage/lcov.info` | +| `INPUT_REGION` | no | `eu` (default), `us`, `au`, or `us-gov` | | `INPUT_FAIL-ON-ERROR` | no | Defaults to `true` | The published action authenticates with GitHub OIDC (`core.getIDToken`). That only works diff --git a/README.md b/README.md index c7a06bc..6ce62ee 100644 --- a/README.md +++ b/README.md @@ -147,8 +147,22 @@ the matrix test jobs. | Input | Required | Default | Description | | ----------------- | -------- | ------- | ------------------------------------------------------------------------------------- | | `lcov-file-paths` | yes | — | Path(s) to the LCOV report file(s). | +| `region` | no | `eu` | Aikido region for upload and OIDC audience: `eu`, `us`, `au`, or `us-gov`. | | `fail-on-error` | no | `true` | Fail the action if reading or upload fails. Set to `false` to emit a warning instead. | +### Region + +Set `region` to match your Aikido workspace. The value selects both the API host and the OIDC +token audience. + +```yaml +- name: Upload coverage to Aikido + uses: AikidoSec/code-coverage-github-action@v1 + with: + lcov-file-paths: coverage/lcov.info + region: us +``` + ## Authentication The action authenticates with GitHub OIDC. The workflow job must grant `id-token: write` diff --git a/__tests__/aikido.test.js b/__tests__/aikido.test.js index 4e9547b..eb96f7a 100644 --- a/__tests__/aikido.test.js +++ b/__tests__/aikido.test.js @@ -18,7 +18,7 @@ jest.unstable_mockModule('@actions/http-client', () => ({ }, })); -const { getAuthHeaders, uploadCoverage } = await import('../src/aikido.js'); +const { getAuthHeaders, getBaseUrl, uploadCoverage } = await import('../src/aikido.js'); function mockResponse(statusCode, rawBody = '') { return { @@ -31,6 +31,34 @@ function decodeCoverageContent(encoded) { return gunzipSync(Buffer.from(encoded, 'base64')).toString('utf8'); } +describe('getBaseUrl', () => { + beforeEach(() => { + delete process.env.DEVELOPMENT; + }); + + it.each([ + ['', 'https://bg.aikido.dev'], + ['eu', 'https://bg.aikido.dev'], + ['EU', 'https://bg.aikido.dev'], + ['us', 'https://bg.us.aikido.dev'], + ['au', 'https://bg.au.aikido.dev'], + ['us-gov', 'https://bg.aikidogov.us'], + ])('maps region %j to %s', (region, url) => { + expect(getBaseUrl(region)).toBe(url); + }); + + it('throws for an unknown region', () => { + expect(() => getBaseUrl('mars')).toThrow( + 'Unknown region "mars". Supported regions: eu, us, au, us-gov', + ); + }); + + it('uses the development URL when DEVELOPMENT is set', () => { + process.env.DEVELOPMENT = 'true'; + expect(getBaseUrl('us')).toBe('https://app.test.aikido.dev'); + }); +}); + describe('getAuthHeaders', () => { beforeEach(() => { delete process.env.DEVELOPMENT; @@ -48,6 +76,14 @@ describe('getAuthHeaders', () => { expect(mockSetSecret).toHaveBeenCalledWith('oidc-jwt'); }); + it('uses the region base URL as the OIDC audience', async () => { + mockGetIDToken.mockResolvedValue('oidc-jwt'); + + await getAuthHeaders('us'); + + expect(mockGetIDToken).toHaveBeenCalledWith('https://bg.us.aikido.dev'); + }); + it('throws a friendly error when OIDC is unavailable', async () => { mockGetIDToken.mockRejectedValue(new Error('OIDC not available')); @@ -55,6 +91,11 @@ describe('getAuthHeaders', () => { 'This action uses OIDC to authenticate with Aikido. Add to your workflow job:\n permissions:\n id-token: write', ); }); + + it('rethrows unknown region errors', async () => { + await expect(getAuthHeaders('mars')).rejects.toThrow('Unknown region "mars"'); + expect(mockGetIDToken).not.toHaveBeenCalled(); + }); }); describe('uploadCoverage', () => { diff --git a/__tests__/inputs.test.js b/__tests__/inputs.test.js index e57b466..f30ed20 100644 --- a/__tests__/inputs.test.js +++ b/__tests__/inputs.test.js @@ -12,7 +12,15 @@ const { readInputs } = await import('../src/inputs.js'); describe('readInputs', () => { beforeEach(() => { - mockGetInput.mockReturnValue('coverage/lcov.info'); + mockGetInput.mockImplementation((name) => { + if (name === 'lcov-file-paths') { + return 'coverage/lcov.info'; + } + if (name === 'region') { + return ''; + } + return ''; + }); mockGetBooleanInput.mockReturnValue(true); }); @@ -20,20 +28,44 @@ describe('readInputs', () => { expect(readInputs()).toEqual({ lcovFilePaths: ['coverage/lcov.info'], failOnError: true, + region: 'eu', }); expect(mockGetInput).toHaveBeenCalledWith('lcov-file-paths', { required: true, trimWhitespace: true, }); + expect(mockGetInput).toHaveBeenCalledWith('region', { + required: false, + trimWhitespace: true, + }); expect(mockGetBooleanInput).toHaveBeenCalledWith('fail-on-error'); }); + it('reads an explicit region', () => { + mockGetInput.mockImplementation((name) => { + if (name === 'lcov-file-paths') { + return 'coverage/lcov.info'; + } + if (name === 'region') { + return 'us'; + } + return ''; + }); + + expect(readInputs().region).toBe('us'); + }); + it.each([ ['newlines', 'packages/a/coverage/lcov.info\npackages/b/coverage/lcov.info'], ['commas', 'packages/a/coverage/lcov.info,packages/b/coverage/lcov.info'], ['spaces', 'packages/a/coverage/lcov.info packages/b/coverage/lcov.info'], ])('splits lcov paths on %s', (_label, input) => { - mockGetInput.mockReturnValue(input); + mockGetInput.mockImplementation((name) => { + if (name === 'lcov-file-paths') { + return input; + } + return ''; + }); expect(readInputs().lcovFilePaths).toEqual([ 'packages/a/coverage/lcov.info', diff --git a/__tests__/integration/multiRegion.test.js b/__tests__/integration/multiRegion.test.js new file mode 100644 index 0000000..74af706 --- /dev/null +++ b/__tests__/integration/multiRegion.test.js @@ -0,0 +1,165 @@ +import { jest } from '@jest/globals'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { gunzipSync } from 'node:zlib'; + +const mockInfo = jest.fn(); +const mockSetFailed = jest.fn(); +const mockWarning = jest.fn(); +const mockGetInput = jest.fn(); +const mockGetBooleanInput = jest.fn(); +const mockPost = jest.fn(); +const mockHttpClient = jest.fn(); +const mockGetIDToken = jest.fn(); +const mockSetSecret = jest.fn(); +const originalGitHubWorkspace = process.env.GITHUB_WORKSPACE; + +function decodeCoverageContent(encoded) { + return gunzipSync(Buffer.from(encoded, 'base64')).toString('utf8'); +} + +jest.unstable_mockModule('@actions/core', () => ({ + info: mockInfo, + setFailed: mockSetFailed, + warning: mockWarning, + getInput: mockGetInput, + getBooleanInput: mockGetBooleanInput, + getIDToken: mockGetIDToken, + setSecret: mockSetSecret, +})); + +jest.unstable_mockModule('@actions/http-client', () => ({ + HttpClient: mockHttpClient, + HttpCodes: { + OK: 200, + }, +})); + +const { run } = await import('../../src/main.js'); + +function mockResponse(statusCode, rawBody = '') { + return { + message: { statusCode }, + readBody: jest.fn().mockResolvedValue(rawBody), + }; +} + +const REGIONS = [ + { region: 'eu', baseUrl: 'https://bg.aikido.dev' }, + { region: 'us', baseUrl: 'https://bg.us.aikido.dev' }, + { region: 'au', baseUrl: 'https://bg.au.aikido.dev' }, + { region: 'us-gov', baseUrl: 'https://bg.aikidogov.us' }, +]; + +describe('e2e multi-region OIDC and upload URLs', () => { + let tmpDir; + const lcovContent = 'TN:\nSF:src/app.js\nDA:1,5\nend_of_record\n'; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'e2e-region-')); + + process.env.GITHUB_REPOSITORY = 'org/repo'; + process.env.GITHUB_SHA = 'abc123'; + process.env.GITHUB_HEAD_REF = 'main'; + process.env.GITHUB_WORKSPACE = tmpDir; + delete process.env.DEVELOPMENT; + + mockInfo.mockClear(); + mockSetFailed.mockClear(); + mockWarning.mockClear(); + mockGetInput.mockClear(); + mockGetBooleanInput.mockClear(); + mockPost.mockClear(); + mockHttpClient.mockClear(); + mockGetIDToken.mockClear(); + mockSetSecret.mockClear(); + + mockGetBooleanInput.mockReturnValue(true); + mockHttpClient.mockImplementation(() => ({ + post: mockPost, + })); + mockPost.mockResolvedValue(mockResponse(200, JSON.stringify({ success: true }))); + mockGetIDToken.mockResolvedValue('oidc-jwt'); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + afterAll(() => { + if (originalGitHubWorkspace === undefined) { + delete process.env.GITHUB_WORKSPACE; + } else { + process.env.GITHUB_WORKSPACE = originalGitHubWorkspace; + } + }); + + function configureInputs(region) { + mockGetInput.mockImplementation((name) => { + if (name === 'lcov-file-paths') { + return 'lcov.info'; + } + if (name === 'region') { + return region; + } + return ''; + }); + } + + it.each(REGIONS)( + 'requests OIDC and uploads to $region without a real network call', + async ({ region, baseUrl }) => { + const previousCwd = process.cwd(); + process.chdir(tmpDir); + + try { + await fs.writeFile('lcov.info', lcovContent); + configureInputs(region); + + await run(); + + expect(mockSetFailed).not.toHaveBeenCalled(); + expect(mockGetIDToken).toHaveBeenCalledWith(baseUrl); + expect(mockSetSecret).toHaveBeenCalledWith('oidc-jwt'); + expect(mockPost).toHaveBeenCalledTimes(1); + + const [url, rawBody, headers] = mockPost.mock.calls[0]; + expect(url).toBe(`${baseUrl}/api/integrations/continuous_integration/scan/code_coverage`); + + const body = JSON.parse(rawBody); + expect(decodeCoverageContent(body.code_coverage_file_content)).toBe(lcovContent); + expect(headers).toEqual({ + Authorization: 'Bearer oidc-jwt', + 'Content-Type': 'application/json', + Accept: 'application/json', + }); + + expect(mockInfo).toHaveBeenCalledWith( + `Uploading coverage report for branch main to Aikido...`, + ); + expect(mockInfo).toHaveBeenCalledWith('Upload succeeded.'); + } finally { + process.chdir(previousCwd); + } + }, + ); + + it('fails cleanly for an unknown region without posting', async () => { + const previousCwd = process.cwd(); + process.chdir(tmpDir); + + try { + await fs.writeFile('lcov.info', lcovContent); + configureInputs('mars'); + + await run(); + + expect(mockPost).not.toHaveBeenCalled(); + expect(mockGetIDToken).not.toHaveBeenCalled(); + expect(mockSetFailed).toHaveBeenCalledWith(expect.stringContaining('Unknown region "mars"')); + } finally { + process.chdir(previousCwd); + } + }); +}); diff --git a/__tests__/main.test.js b/__tests__/main.test.js index 7f981fe..4282876 100644 --- a/__tests__/main.test.js +++ b/__tests__/main.test.js @@ -70,7 +70,18 @@ describe('main.js security - single file path validation', () => { mockSetSecret.mockClear(); // Default mock implementations - mockGetBooleanInput.mockReturnValue(true); + mockGetInput.mockImplementation((name) => { + if (name === 'region') { + return 'eu'; + } + return ''; + }); + mockGetBooleanInput.mockImplementation((name) => { + if (name === 'fail-on-error') { + return true; + } + return false; + }); mockHttpClient.mockImplementation(() => ({ post: mockPost, })); @@ -78,6 +89,18 @@ describe('main.js security - single file path validation', () => { mockGetIDToken.mockResolvedValue('oidc-jwt'); }); + function setLcovInput(value) { + mockGetInput.mockImplementation((name) => { + if (name === 'lcov-file-paths') { + return value; + } + if (name === 'region') { + return 'eu'; + } + return ''; + }); + } + afterEach(async () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); @@ -100,7 +123,7 @@ describe('main.js security - single file path validation', () => { await fs.writeFile('lcov.info', 'TN:\nSF:test.js\nend_of_record\n'); // Attempt to use path traversal - mockGetInput.mockReturnValue('../../../etc/passwd'); + setLcovInput('../../../etc/passwd'); await run(); @@ -120,7 +143,7 @@ describe('main.js security - single file path validation', () => { process.chdir(tmpDir); try { - mockGetInput.mockReturnValue('../../sensitive/file.txt'); + setLcovInput('../../sensitive/file.txt'); await run(); @@ -140,7 +163,7 @@ describe('main.js security - single file path validation', () => { process.chdir(tmpDir); try { - mockGetInput.mockReturnValue('coverage/../../../etc/passwd'); + setLcovInput('coverage/../../../etc/passwd'); await run(); @@ -162,7 +185,7 @@ describe('main.js security - single file path validation', () => { process.chdir(tmpDir); try { - mockGetInput.mockReturnValue('/etc/passwd'); + setLcovInput('/etc/passwd'); await run(); @@ -184,7 +207,7 @@ describe('main.js security - single file path validation', () => { try { // Windows absolute path - only test on Windows if (process.platform === 'win32') { - mockGetInput.mockReturnValue('C:\\Windows\\System32\\config\\SAM'); + setLcovInput('C:\\Windows\\System32\\config\\SAM'); await run(); @@ -196,7 +219,7 @@ describe('main.js security - single file path validation', () => { expect(mockPost).not.toHaveBeenCalled(); } else { // On Unix, test with a Unix absolute path instead - mockGetInput.mockReturnValue('/var/log/system.log'); + setLcovInput('/var/log/system.log'); await run(); @@ -259,7 +282,7 @@ describe('main.js security - single file path validation', () => { const lcovContent = 'TN:\nSF:src/test.js\nDA:1,5\nend_of_record\n'; await fs.writeFile('lcov.info', lcovContent); - mockGetInput.mockReturnValue('lcov.info'); + setLcovInput('lcov.info'); await run(); @@ -280,7 +303,7 @@ describe('main.js security - single file path validation', () => { expect(headers['Content-Encoding']).toBeUndefined(); expect(mockInfo).not.toHaveBeenCalledWith( - `Uploading coverage report for branch haahah to Aikido...`, + `Uploading coverage report for branch haha to Aikido...`, ); expect(mockInfo).toHaveBeenCalledWith( `Uploading coverage report for branch main to Aikido...`, @@ -300,7 +323,7 @@ describe('main.js security - single file path validation', () => { const lcovContent = 'TN:\nSF:src/app.js\nDA:1,10\nend_of_record\n'; await fs.writeFile('coverage/lcov.info', lcovContent); - mockGetInput.mockReturnValue('coverage/lcov.info'); + setLcovInput('coverage/lcov.info'); await run(); @@ -327,7 +350,7 @@ describe('main.js security - single file path validation', () => { try { const absoluteSourcePath = path.join(tmpDir, 'src/app.js'); await fs.writeFile('lcov.info', `TN:\nSF:${absoluteSourcePath}\nDA:1,10\nend_of_record\n`); - mockGetInput.mockReturnValue('lcov.info'); + setLcovInput('lcov.info'); await run(); @@ -350,7 +373,7 @@ describe('main.js security - single file path validation', () => { process.env.GITHUB_WORKSPACE = 'D:\\a\\repo\\repo'; const lcovContent = 'TN:\nSF:D:\\a\\repo\\repo\\src\\app.cs\nDA:1,10\nend_of_record\n'; await fs.writeFile('lcov.info', lcovContent); - mockGetInput.mockReturnValue('lcov.info'); + setLcovInput('lcov.info'); await run(); @@ -377,7 +400,7 @@ describe('main.js security - single file path validation', () => { await fs.writeFile('lcov1.info', lcov1); await fs.writeFile('lcov2.info', lcov2); - mockGetInput.mockReturnValue('lcov1.info lcov2.info'); + setLcovInput('lcov1.info lcov2.info'); await run(); @@ -397,7 +420,7 @@ describe('main.js security - single file path validation', () => { await fs.writeFile('lcov1.info', 'TN:\nSF:src/a.js\nDA:1,5\nend_of_record\n'); // One valid path, one with traversal - mockGetInput.mockReturnValue('lcov1.info ../../../etc/passwd'); + setLcovInput('lcov1.info ../../../etc/passwd'); await run(); @@ -416,7 +439,7 @@ describe('main.js security - single file path validation', () => { await fs.writeFile('lcov1.info', 'TN:\nSF:src/a.js\nDA:1,5\nend_of_record\n'); // One valid path, one absolute - mockGetInput.mockReturnValue('lcov1.info /etc/passwd'); + setLcovInput('lcov1.info /etc/passwd'); await run(); @@ -434,8 +457,13 @@ describe('main.js security - single file path validation', () => { process.chdir(tmpDir); try { - mockGetBooleanInput.mockReturnValue(false); - mockGetInput.mockReturnValue('../../../etc/passwd'); + mockGetBooleanInput.mockImplementation((name) => { + if (name === 'fail-on-error') { + return false; + } + return false; + }); + setLcovInput('../../../etc/passwd'); await run(); @@ -459,7 +487,7 @@ describe('main.js security - single file path validation', () => { try { // Simulate attacker trying to read /etc/passwd - mockGetInput.mockReturnValue('/etc/passwd'); + setLcovInput('/etc/passwd'); await run(); @@ -483,7 +511,7 @@ describe('main.js security - single file path validation', () => { try { // Simulate attacker trying to read runner secrets or environment files - mockGetInput.mockReturnValue('../../.env'); + setLcovInput('../../.env'); await run(); @@ -507,7 +535,7 @@ describe('main.js security - single file path validation', () => { try { // Complex path traversal attempt - mockGetInput.mockReturnValue('coverage/../../../../../../home/runner/.ssh/id_rsa'); + setLcovInput('coverage/../../../../../../home/runner/.ssh/id_rsa'); await run(); @@ -533,7 +561,7 @@ describe('main.js security - single file path validation', () => { try { // Use a path that would fail validation - mockGetInput.mockReturnValue('../sensitive.txt'); + setLcovInput('../sensitive.txt'); await run(); diff --git a/action.yml b/action.yml index 0bc3464..2a7f909 100644 --- a/action.yml +++ b/action.yml @@ -9,6 +9,10 @@ inputs: lcov-file-paths: description: 'Path(s) to the LCOV coverage report(s). Separate multiple entries with newlines' required: true + region: + description: 'Aikido region for upload and OIDC audience. One of: eu, us, au, us-gov.' + required: false + default: 'eu' fail-on-error: description: 'Fail the action if discovery or upload fails. Set to false to warn instead.' required: false diff --git a/package.json b/package.json index 93bb60e..d3788cf 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "format": "prettier --write \"**/*.js\"", "format:check": "prettier --check \"**/*.js\"", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage", + "test:integration": "node --experimental-vm-modules node_modules/jest/bin/jest.js __tests__/integration", "local": "local-action . src/main.js .env", "all": "npm run format && npm run lint && npm run test && npm run build" }, diff --git a/src/aikido.js b/src/aikido.js index 49d1b15..50edf62 100644 --- a/src/aikido.js +++ b/src/aikido.js @@ -2,8 +2,29 @@ import * as core from '@actions/core'; import { HttpClient, HttpCodes } from '@actions/http-client'; import { gzipSync } from 'node:zlib'; -const BASE_URL = process.env.DEVELOPMENT ? 'https://app.test.aikido.dev' : 'https://bg.aikido.dev'; -const OIDC_AUDIENCE = BASE_URL; +const REGION_BASE_URLS = { + eu: 'https://bg.aikido.dev', + us: 'https://bg.us.aikido.dev', + au: 'https://bg.au.aikido.dev', + 'us-gov': 'https://bg.aikidogov.us', +}; + +export function getBaseUrl(region = '') { + if (process.env.DEVELOPMENT) { + return 'https://app.test.aikido.dev'; + } + + const normalized = (region || 'eu').toLowerCase().trim(); + const baseUrl = REGION_BASE_URLS[normalized]; + + if (!baseUrl) { + throw new Error( + `Unknown region "${region}". Supported regions: ${Object.keys(REGION_BASE_URLS).join(', ')}`, + ); + } + + return baseUrl; +} function parseJsonBody(rawBody) { if (!rawBody) { @@ -30,9 +51,11 @@ function formatRequestError(statusCode, result, rawBody) { /** * Resolve request authentication headers for secret-key or OIDC mode. */ -export async function getAuthHeaders() { +export async function getAuthHeaders(region = '') { + const oidcAudience = getBaseUrl(region); + try { - const oidcToken = await core.getIDToken(OIDC_AUDIENCE); + const oidcToken = await core.getIDToken(oidcAudience); core.setSecret(oidcToken); return { Authorization: `Bearer ${oidcToken}` }; @@ -48,8 +71,8 @@ export async function getAuthHeaders() { /** * Upload a coverage payload to Aikido. */ -export async function uploadCoverage(codeCoverageFileContent) { - const authHeaders = await getAuthHeaders(); +export async function uploadCoverage(codeCoverageFileContent, region = '') { + const authHeaders = await getAuthHeaders(region); const client = new HttpClient('aikido-code-coverage'); const body = { @@ -59,7 +82,8 @@ export async function uploadCoverage(codeCoverageFileContent) { code_coverage_file_content: gzipSync(codeCoverageFileContent).toString('base64'), }; - const url = `${BASE_URL}/api/integrations/continuous_integration/scan/code_coverage`; + const baseUrl = getBaseUrl(region); + const url = `${baseUrl}/api/integrations/continuous_integration/scan/code_coverage`; const response = await client.post(url, JSON.stringify(body), { ...authHeaders, diff --git a/src/inputs.js b/src/inputs.js index 2b5e120..b4e97b1 100644 --- a/src/inputs.js +++ b/src/inputs.js @@ -15,9 +15,11 @@ export function readInputs() { .filter(Boolean); const failOnError = core.getBooleanInput('fail-on-error'); + const region = core.getInput('region', { required: false, trimWhitespace: true }) || 'eu'; return { lcovFilePaths, failOnError, + region, }; } diff --git a/src/main.js b/src/main.js index e44058b..46c83a1 100644 --- a/src/main.js +++ b/src/main.js @@ -55,7 +55,7 @@ async function run() { core.info( `Uploading coverage report for branch ${process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME} to Aikido...`, ); - await uploadCoverage(codeCoverageFileContent); + await uploadCoverage(codeCoverageFileContent, inputs.region); core.info(`Upload succeeded.`); } catch (error) {