From de1c80cc639283f568a1f254a48caa5f74506cae Mon Sep 17 00:00:00 2001 From: matthiasgekiere Date: Thu, 10 Sep 2026 11:28:43 +0200 Subject: [PATCH 1/4] Add multi region support Signed-off-by: matthiasgekiere --- __tests__/aikido.test.js | 33 +++++++++++++++++++++++++++++++++ src/aikido.js | 32 +++++++++++++++++++++++++------- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/__tests__/aikido.test.js b/__tests__/aikido.test.js index 4e9547b..1f2bcde 100644 --- a/__tests__/aikido.test.js +++ b/__tests__/aikido.test.js @@ -144,4 +144,37 @@ describe('uploadCoverage', () => { 'Aikido upload failed: Request failed with status code 500 - Internal server error', ); }); + + describe('multi-region support', () => { + beforeEach(() => { + delete process.env.DEVELOPMENT; + }); + + it.each([ + { + region: 'us', + url: 'https://bg.us.aikido.dev', + }, + { + region: 'me', + url: 'https://bg.me.aikido.dev', + }, + { + region: 'au', + url: 'https://bg.au.aikido.dev', + }, + { + region: 'us-gov', + url: 'https://bg.aikidogov.us', + }, + { + region: '', + url: 'https://bg.aikido.dev', + }, + ])('uses the %s region', async ({ region, url }) => { + await uploadCoverage(codeCoverageFileContent, region); + const [actualUrl] = mockPost.mock.calls[0]; + expect(actualUrl).toContain(url); + }); + }); }); diff --git a/src/aikido.js b/src/aikido.js index 49d1b15..69a41b1 100644 --- a/src/aikido.js +++ b/src/aikido.js @@ -2,8 +2,24 @@ 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; +function getBaseUrl(region) { + if (process.env.DEVELOPMENT) { + return 'https://app.test.aikido.dev'; + } + + switch (region) { + case 'us': + return 'https://bg.us.aikido.dev'; + case 'me': + return 'https://bg.me.aikido.dev'; + case 'au': + return 'https://bg.au.aikido.dev'; + case 'us-gov': + return 'https://bg.aikidogov.us'; + default: + return 'https://bg.aikido.dev'; + } +} function parseJsonBody(rawBody) { if (!rawBody) { @@ -30,9 +46,10 @@ function formatRequestError(statusCode, result, rawBody) { /** * Resolve request authentication headers for secret-key or OIDC mode. */ -export async function getAuthHeaders() { +export async function getAuthHeaders(region = '') { try { - const oidcToken = await core.getIDToken(OIDC_AUDIENCE); + const oidcAudience = getBaseUrl(region); + const oidcToken = await core.getIDToken(oidcAudience); core.setSecret(oidcToken); return { Authorization: `Bearer ${oidcToken}` }; @@ -48,8 +65,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 +76,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, From 51af13dcfe3b300b4eb5444f01fecd7c955b5c8c Mon Sep 17 00:00:00 2001 From: matthiasgekiere Date: Thu, 10 Sep 2026 12:09:46 +0200 Subject: [PATCH 2/4] Add region input support and update tests - Added optional `region` input to the action, defaulting to 'eu'. - Updated action documentation to reflect new region input. - Enhanced test suite with integration tests for multi-region support. - Refactored `getBaseUrl` function to use a mapping for region URLs. - Updated existing tests to accommodate the new region handling. Signed-off-by: matthiasgekiere --- .env.example | 1 + .github/workflows/ci.yml | 5 +- README.dev.md | 4 +- README.md | 14 ++ __tests__/aikido.test.js | 77 +++++----- __tests__/inputs.test.js | 36 ++++- __tests__/integration/multiRegion.test.js | 164 ++++++++++++++++++++++ __tests__/main.test.js | 68 ++++++--- action.yml | 4 + package.json | 1 + src/aikido.js | 36 +++-- src/inputs.js | 2 + src/main.js | 4 +- 13 files changed, 343 insertions(+), 73 deletions(-) create mode 100644 __tests__/integration/multiRegion.test.js 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..e0ab57f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,9 +28,12 @@ jobs: - name: Lint run: npm run lint - - name: Test + - name: Unit Tests run: npm test + - name: Integration Tests + run: npm run test:integration + - name: Build run: npm run build diff --git a/README.dev.md b/README.dev.md index 7170fe3..8603e70 100644 --- a/README.dev.md +++ b/README.dev.md @@ -21,7 +21,8 @@ npm install | Script | Description | | ---------------- | -------------------------------------------------- | -| `npm test` | Run unit tests with Jest | +| `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 | @@ -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`, `me`, `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..a918190 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`, `me`, `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 1f2bcde..ae5907d 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,35 @@ 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'], + ['me', 'https://bg.me.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, me, 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 +77,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 +92,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', () => { @@ -144,37 +186,4 @@ describe('uploadCoverage', () => { 'Aikido upload failed: Request failed with status code 500 - Internal server error', ); }); - - describe('multi-region support', () => { - beforeEach(() => { - delete process.env.DEVELOPMENT; - }); - - it.each([ - { - region: 'us', - url: 'https://bg.us.aikido.dev', - }, - { - region: 'me', - url: 'https://bg.me.aikido.dev', - }, - { - region: 'au', - url: 'https://bg.au.aikido.dev', - }, - { - region: 'us-gov', - url: 'https://bg.aikidogov.us', - }, - { - region: '', - url: 'https://bg.aikido.dev', - }, - ])('uses the %s region', async ({ region, url }) => { - await uploadCoverage(codeCoverageFileContent, region); - const [actualUrl] = mockPost.mock.calls[0]; - expect(actualUrl).toContain(url); - }); - }); }); 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..f1ef91f --- /dev/null +++ b/__tests__/integration/multiRegion.test.js @@ -0,0 +1,164 @@ +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: 'me', baseUrl: 'https://bg.me.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 to Aikido (${region})...`); + 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 f882f64..a04e182 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(); @@ -222,7 +245,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(); @@ -257,7 +280,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(); @@ -284,7 +307,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(); @@ -307,7 +330,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(); @@ -334,7 +357,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(); @@ -354,7 +377,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(); @@ -373,7 +396,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(); @@ -391,8 +414,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(); @@ -416,7 +444,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(); @@ -440,7 +468,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(); @@ -464,7 +492,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(); @@ -490,7 +518,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..5428c5a 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, me, 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 69a41b1..6bbed1e 100644 --- a/src/aikido.js +++ b/src/aikido.js @@ -2,23 +2,29 @@ import * as core from '@actions/core'; import { HttpClient, HttpCodes } from '@actions/http-client'; import { gzipSync } from 'node:zlib'; -function getBaseUrl(region) { +const REGION_BASE_URLS = { + eu: 'https://bg.aikido.dev', + us: 'https://bg.us.aikido.dev', + me: 'https://bg.me.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'; } - switch (region) { - case 'us': - return 'https://bg.us.aikido.dev'; - case 'me': - return 'https://bg.me.aikido.dev'; - case 'au': - return 'https://bg.au.aikido.dev'; - case 'us-gov': - return 'https://bg.aikidogov.us'; - default: - return 'https://bg.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) { @@ -53,7 +59,11 @@ export async function getAuthHeaders(region = '') { core.setSecret(oidcToken); return { Authorization: `Bearer ${oidcToken}` }; - } catch { + } catch (error) { + if (error instanceof Error && error.message.startsWith('Unknown region')) { + throw error; + } + throw new Error( 'This action uses OIDC to authenticate with Aikido. Add to your workflow job:\n' + ' permissions:\n' + 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 0ffbac6..b8c3f34 100644 --- a/src/main.js +++ b/src/main.js @@ -50,8 +50,8 @@ async function run() { throw new Error('Something went wrong while validating the coverage file(s)'); } - core.info('Uploading coverage report to Aikido...'); - await uploadCoverage(codeCoverageFileContent); + core.info(`Uploading coverage report to Aikido (${inputs.region})...`); + await uploadCoverage(codeCoverageFileContent, inputs.region); core.info(`Upload succeeded.`); } catch (error) { From 93f610951df59004e9e7079333aa58c47faa6182 Mon Sep 17 00:00:00 2001 From: matthiasgekiere Date: Thu, 10 Sep 2026 12:20:05 +0200 Subject: [PATCH 3/4] Refactor GitHub Actions workflow and improve error handling in getAuthHeaders Signed-off-by: matthiasgekiere --- .github/workflows/ci.yml | 105 +++++++++++++++++++++++++++++++++------ src/aikido.js | 9 ++-- 2 files changed, 93 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0ab57f..83fd21e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,12 +4,8 @@ on: push: {} jobs: - build-and-test: + format: runs-on: open-source-releaser - permissions: - packages: write - contents: write - id-token: write steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: @@ -21,21 +17,100 @@ jobs: cache: npm - run: npm ci + - run: npm run format:check - - name: Check formatting - run: npm run format:check + lint: + 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 - - name: Unit Tests - run: npm test + - run: npm ci + - run: npm run lint - - name: Integration Tests - run: npm run test:integration + test: + runs-on: open-source-releaser + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - - name: Build - run: npm run build + - 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 + 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 test:integration + + build: + 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 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 + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: coverage + path: coverage/ + + - 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/src/aikido.js b/src/aikido.js index 6bbed1e..1632a83 100644 --- a/src/aikido.js +++ b/src/aikido.js @@ -53,17 +53,14 @@ function formatRequestError(statusCode, result, rawBody) { * Resolve request authentication headers for secret-key or OIDC mode. */ export async function getAuthHeaders(region = '') { + const oidcAudience = getBaseUrl(region); + try { - const oidcAudience = getBaseUrl(region); const oidcToken = await core.getIDToken(oidcAudience); core.setSecret(oidcToken); return { Authorization: `Bearer ${oidcToken}` }; - } catch (error) { - if (error instanceof Error && error.message.startsWith('Unknown region')) { - throw error; - } - + } catch { throw new Error( 'This action uses OIDC to authenticate with Aikido. Add to your workflow job:\n' + ' permissions:\n' + From d5cafddcfc4c4625cafda74c0b953cf1162fd745 Mon Sep 17 00:00:00 2001 From: matthiasgekiere Date: Thu, 10 Sep 2026 15:24:50 +0200 Subject: [PATCH 4/4] remove me and don't log regions Signed-off-by: matthiasgekiere --- README.dev.md | 20 ++++++++++---------- README.md | 2 +- __tests__/aikido.test.js | 3 +-- __tests__/integration/multiRegion.test.js | 3 +-- __tests__/main.test.js | 4 ++-- action.yml | 2 +- src/aikido.js | 1 - src/main.js | 2 +- 8 files changed, 17 insertions(+), 20 deletions(-) diff --git a/README.dev.md b/README.dev.md index 8603e70..e87aa5c 100644 --- a/README.dev.md +++ b/README.dev.md @@ -19,15 +19,15 @@ npm install ## npm scripts -| 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 | +| 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: @@ -58,7 +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`, `me`, `au`, or `us-gov` | +| `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 a918190..6ce62ee 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ 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`, `me`, `au`, or `us-gov`. | +| `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 diff --git a/__tests__/aikido.test.js b/__tests__/aikido.test.js index ae5907d..eb96f7a 100644 --- a/__tests__/aikido.test.js +++ b/__tests__/aikido.test.js @@ -41,7 +41,6 @@ describe('getBaseUrl', () => { ['eu', 'https://bg.aikido.dev'], ['EU', 'https://bg.aikido.dev'], ['us', 'https://bg.us.aikido.dev'], - ['me', 'https://bg.me.aikido.dev'], ['au', 'https://bg.au.aikido.dev'], ['us-gov', 'https://bg.aikidogov.us'], ])('maps region %j to %s', (region, url) => { @@ -50,7 +49,7 @@ describe('getBaseUrl', () => { it('throws for an unknown region', () => { expect(() => getBaseUrl('mars')).toThrow( - 'Unknown region "mars". Supported regions: eu, us, me, au, us-gov', + 'Unknown region "mars". Supported regions: eu, us, au, us-gov', ); }); diff --git a/__tests__/integration/multiRegion.test.js b/__tests__/integration/multiRegion.test.js index b122d61..74af706 100644 --- a/__tests__/integration/multiRegion.test.js +++ b/__tests__/integration/multiRegion.test.js @@ -48,7 +48,6 @@ function mockResponse(statusCode, rawBody = '') { const REGIONS = [ { region: 'eu', baseUrl: 'https://bg.aikido.dev' }, { region: 'us', baseUrl: 'https://bg.us.aikido.dev' }, - { region: 'me', baseUrl: 'https://bg.me.aikido.dev' }, { region: 'au', baseUrl: 'https://bg.au.aikido.dev' }, { region: 'us-gov', baseUrl: 'https://bg.aikidogov.us' }, ]; @@ -137,7 +136,7 @@ describe('e2e multi-region OIDC and upload URLs', () => { }); expect(mockInfo).toHaveBeenCalledWith( - `Uploading coverage report for branch main and region ${region} to Aikido...`, + `Uploading coverage report for branch main to Aikido...`, ); expect(mockInfo).toHaveBeenCalledWith('Upload succeeded.'); } finally { diff --git a/__tests__/main.test.js b/__tests__/main.test.js index 612484a..4282876 100644 --- a/__tests__/main.test.js +++ b/__tests__/main.test.js @@ -303,10 +303,10 @@ describe('main.js security - single file path validation', () => { expect(headers['Content-Encoding']).toBeUndefined(); expect(mockInfo).not.toHaveBeenCalledWith( - `Uploading coverage report for branch haha and region eu to Aikido...`, + `Uploading coverage report for branch haha to Aikido...`, ); expect(mockInfo).toHaveBeenCalledWith( - `Uploading coverage report for branch main and region eu to Aikido...`, + `Uploading coverage report for branch main to Aikido...`, ); expect(mockInfo).toHaveBeenCalledWith('Upload succeeded.'); } finally { diff --git a/action.yml b/action.yml index 5428c5a..2a7f909 100644 --- a/action.yml +++ b/action.yml @@ -10,7 +10,7 @@ inputs: 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, me, au, us-gov.' + description: 'Aikido region for upload and OIDC audience. One of: eu, us, au, us-gov.' required: false default: 'eu' fail-on-error: diff --git a/src/aikido.js b/src/aikido.js index 1632a83..50edf62 100644 --- a/src/aikido.js +++ b/src/aikido.js @@ -5,7 +5,6 @@ import { gzipSync } from 'node:zlib'; const REGION_BASE_URLS = { eu: 'https://bg.aikido.dev', us: 'https://bg.us.aikido.dev', - me: 'https://bg.me.aikido.dev', au: 'https://bg.au.aikido.dev', 'us-gov': 'https://bg.aikidogov.us', }; diff --git a/src/main.js b/src/main.js index bae6b5c..46c83a1 100644 --- a/src/main.js +++ b/src/main.js @@ -53,7 +53,7 @@ async function run() { } core.info( - `Uploading coverage report for branch ${process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME} and region ${inputs.region} to Aikido...`, + `Uploading coverage report for branch ${process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME} to Aikido...`, ); await uploadCoverage(codeCoverageFileContent, inputs.region);