Skip to content
Open
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
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ ACTIONS_STEP_DEBUG=true
# Action inputs
#
# GitHub maps action.yml inputs to INPUT_<NAME> (uppercase). Hyphens are kept —
# do NOT use underscores (INPUT_LCOV_FILE_PATHS will not work).
# do NOT use underscores (INPUT_FILE_PATHS will not work).
# See: https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#inputs
################################################################################

INPUT_LCOV-FILE-PATHS=coverage/lcov.info
INPUT_FILE-PATHS=coverage/lcov.info
# INPUT_REGION=eu
# INPUT_FAIL-ON-ERROR=true

Expand Down
13 changes: 10 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,15 @@ jobs:

- run: npm ci
- run: npm test
# Jest writes cobertura-coverage.xml; rename for the action dogfood step.
- run: cp coverage/cobertura-coverage.xml coverage/cobertura.xml

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage
path: coverage/lcov.info
path: |
coverage/lcov.info
coverage/cobertura.xml
if-no-files-found: error

test-integration:
Expand Down Expand Up @@ -112,9 +116,12 @@ jobs:
name: dist
path: dist/

- name: Test Aikido Upload Code Coverage action in workflow with OIDC
- name: Test Aikido Upload Code Coverage action in workflow with LCOV format
uses: ./
with:
lcov-file-paths: coverage/lcov.info
file-paths: |
coverage/lcov.info
coverage/cobertura.xml
env:
ACTIONS_STEP_DEBUG: 'true'
DEVELOPMENT: 'true'
45 changes: 25 additions & 20 deletions README.dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,22 +55,20 @@ The `.env` file has two groups of variables.

GitHub Actions inputs are exposed as environment variables with an `INPUT_` prefix. Use the input name from `action.yml` in uppercase. **Keep hyphens — do not replace them with underscores.**

| 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` |
| Variable | Required | Description |
| --------------------- | -------- | ------------------------------------------------------- |
| `INPUT_FILE-PATHS` | yes | Path(s) to coverage 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
inside GitHub Actions when the job has `permissions: id-token: write`. Local `npm run local`
runs can still exercise file discovery and merge, but the upload step will fail without a
real OIDC token.

For multiple LCOV files, separate paths with newlines, spaces, or commas (same parsing as in CI):
For multiple coverage files, separate paths with newlines, spaces, or commas (same parsing as in CI). Mixed LCOV and Cobertura paths are fine:

```dotenv
INPUT_LCOV-FILE-PATHS=packages/a/coverage/lcov.info
packages/b/coverage/lcov.info
INPUT_FILE-PATHS=packages/a/coverage/lcov.info
packages/b/coverage/cobertura.xml
```

#### GitHub context
Expand All @@ -83,9 +81,10 @@ In CI, GitHub sets repository metadata automatically. Locally, set these in `.en
| `GITHUB_SHA` | `abc123def456...` (any valid commit SHA) |
| `GITHUB_REF_NAME` | `main` |

### 3. Provide an LCOV file
### 3. Provide a coverage file

Point `INPUT_LCOV-FILE-PATHS` at an existing LCOV report. To generate one in this repo:
Point `INPUT_FILE-PATHS` at an existing report. Use a filename the action can detect
(`lcov.info`, `*.lcov`, or `*cobertura*.xml` / `*.xml`). To generate an LCOV file in this repo:

```bash
npm test
Expand Down Expand Up @@ -147,20 +146,26 @@ Publishing to GitHub Marketplace is a manual step in the GitHub UI. The release
## Project layout

```
action.yml Action metadata and inputs
action.yml Action metadata and inputs
src/
main.js Entry point (used for local runs)
inputs.js Reads action inputs via @actions/core
mergeLcov.js Merges multiple LCOV files
aikido.js Uploads coverage to the Aikido API
main.js Entry point (used for local runs)
inputs.js Reads action inputs via @actions/core
collectUploadPayload.js Builds repository_source_paths + EOF + file list for upload
reportPaths.js Format detection / covered-path extraction
projectFiles.js Repository walk → repository_source_paths
sourceLineFixes.js EOF line counts from source files
aikido.js Uploads coverage payload to the Aikido API
php/ Portable PHP merge/parse extract for the backend
dist/
index.js Bundled output (used in CI workflows)
__tests__/ Jest unit tests
.env.example Template for local testing
index.js Bundled output (used in CI workflows)
__tests__/ Jest unit tests
.env.example Template for local testing
```

Local runs execute `src/main.js` directly. Published workflows use the bundled `dist/index.js` built by `npm run build`.

See [`php/README.md`](./php/README.md) for the backend processor extract.

## Authentication

The action always uses GitHub OIDC. There is no CI API token input. In a workflow, grant
Expand Down
57 changes: 36 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
# Aikido Code Coverage GitHub Action

Collect an [LCOV](https://github.com/linux-test-project/lcov) code coverage report produced by your
test suite and upload it to [Aikido](https://www.aikido.dev/).
Collect [LCOV](https://github.com/linux-test-project/lcov) or [Cobertura](https://cobertura.github.io/cobertura/) XML code coverage reports produced by your test suite and upload them to [Aikido](https://www.aikido.dev/).

The action reads one or more LCOV reports from the paths you provide. When multiple reports are
given, it merges them into a single file before upload. It then POSTs the LCOV content to the Aikido CI code coverage API together with the
repository name, commit SHA, and branch name.
The action reads one or more reports from the paths you provide and uploads them **as-is** (no local merge), together with:

- a **`repository_source_paths`** list (filtered repo source paths) for path matching on the backend
- an **EOF** map (line counts for covered source files) so the backend can drop coverage past end-of-file

Authentication uses GitHub OIDC (keyless). The job that runs this action must grant
`id-token: write`. No API token or repository secret is required.

## Usage

Run your tests with coverage first, then point this action at the generated LCOV file.
Run your tests with coverage first, then point this action at the generated report.

Example YAML file:
Example YAML file (LCOV):

```yaml
name: Tests
Expand Down Expand Up @@ -49,6 +49,8 @@ jobs:
id-token: write # required for upload
contents: read # required for upload
steps:
- uses: actions/checkout@v4

- uses: actions/download-artifact@v4
with:
name: coverage
Expand All @@ -57,26 +59,35 @@ jobs:
- name: Upload coverage to Aikido
uses: AikidoSec/code-coverage-github-action@v1.1.0
with:
lcov-file-paths: coverage/lcov.info
file-paths: coverage/lcov.info
```

### Cobertura XML

```yaml
- name: Upload coverage to Aikido
uses: AikidoSec/code-coverage-github-action@v1.1.0
with:
file-paths: coverage/cobertura.xml
```

### Uploading multiple reports

Provide more than one path when separate packages or CI shards each emit their own `lcov.info`. The
action merges all inputs into one upload.
Provide more than one path when separate packages or CI shards each emit their own report.
Mixed LCOV and Cobertura inputs are supported; the backend merges them.

```yaml
- name: Upload coverage to Aikido
uses: AikidoSec/code-coverage-github-action@v1.1.0
with:
lcov-file-paths: |
file-paths: |
packages/a/coverage/lcov.info
packages/b/coverage/lcov.info
packages/b/coverage/cobertura.xml
```

### Monorepo with matrix jobs

When each package runs in its own job, LCOV files live on separate runners. Use
When each package runs in its own job, coverage files live on separate runners. Use
[`actions/upload-artifact`](https://github.com/actions/upload-artifact) and
[`actions/download-artifact`](https://github.com/actions/download-artifact) to collect
reports in a final job, then upload once to Aikido.
Expand Down Expand Up @@ -120,6 +131,8 @@ jobs:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4

- uses: actions/download-artifact@v4
with:
path: coverage-reports
Expand All @@ -129,7 +142,7 @@ jobs:
- name: Upload coverage to Aikido
uses: AikidoSec/code-coverage-github-action@v1.1.0
with:
lcov-file-paths: |
file-paths: |
coverage-reports/packages/a/coverage/lcov.info
coverage-reports/packages/b/coverage/lcov.info
coverage-reports/packages/c/coverage/lcov.info
Expand All @@ -144,11 +157,11 @@ the matrix test jobs.

## Inputs

| 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. |
| Input | Required | Default | Description |
| --------------- | -------- | ------- | --------------------------------------------------------------------------------------------------- |
| `file-paths` | yes | — | Path(s) to coverage report(s). Newline-, space-, or comma-separated. Format detected from filename. |
| `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

Expand All @@ -159,7 +172,7 @@ token audience.
- name: Upload coverage to Aikido
uses: AikidoSec/code-coverage-github-action@v1.1.0
with:
lcov-file-paths: coverage/lcov.info
file-paths: coverage/lcov.info
region: us
```

Expand Down Expand Up @@ -194,6 +207,8 @@ jobs:
id-token: write # required for upload
contents: read # required for upload
steps:
- uses: actions/checkout@v4

- uses: actions/download-artifact@v4
with:
name: coverage
Expand All @@ -202,5 +217,5 @@ jobs:
- name: Upload coverage to Aikido
uses: AikidoSec/code-coverage-github-action@v1.1.0
with:
lcov-file-paths: coverage/lcov.info
file-paths: coverage/lcov.info
```
53 changes: 37 additions & 16 deletions __tests__/aikido.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ function decodeCoverageContent(encoded) {
return gunzipSync(Buffer.from(encoded, 'base64')).toString('utf8');
}

function samplePayload(content = 'TN:\nSF:a\nend_of_record\n', format = 'lcov') {
return {
repository_source_paths: ['src/a.js'],
eof: { 'src/a.js': 3 },
files: [{ filename: 'lcov.info', format, content }],
};
}

describe('getBaseUrl', () => {
beforeEach(() => {
delete process.env.DEVELOPMENT;
Expand Down Expand Up @@ -99,8 +107,6 @@ describe('getAuthHeaders', () => {
});

describe('uploadCoverage', () => {
const codeCoverageFileContent = 'TN:\nSF:a\nend_of_record\n';

beforeEach(() => {
process.env.GITHUB_REPOSITORY = 'org/repo';
process.env.GITHUB_SHA = 'abc123';
Expand All @@ -114,8 +120,9 @@ describe('uploadCoverage', () => {
mockSetSecret.mockReset();
});

it('posts the coverage payload with a bearer token', async () => {
const result = await uploadCoverage(codeCoverageFileContent);
it('posts files, repository_source_paths, and eof with a bearer token', async () => {
const payload = samplePayload();
const result = await uploadCoverage(payload);

expect(result).toEqual({ success: true });
expect(mockGetIDToken).toHaveBeenCalledWith('https://bg.aikido.dev');
Expand All @@ -127,20 +134,34 @@ describe('uploadCoverage', () => {
'https://bg.aikido.dev/api/integrations/continuous_integration/scan/code_coverage',
);
const body = JSON.parse(rawBody);
expect(body).toEqual({
repo_name: 'org/repo',
commit_sha: 'abc123',
branch_name: 'main',
code_coverage_file_content: expect.any(String),
});
expect(decodeCoverageContent(body.code_coverage_file_content)).toBe(codeCoverageFileContent);
expect(body.repo_name).toBe('org/repo');
expect(body.commit_sha).toBe('abc123');
expect(body.branch_name).toBe('main');
expect(body.repository_source_paths).toEqual(['src/a.js']);
expect(body.eof).toEqual({ 'src/a.js': 3 });
expect(body.files).toHaveLength(1);
expect(body.files[0].filename).toBe('lcov.info');
expect(body.files[0].format).toBe('lcov');
expect(decodeCoverageContent(body.files[0].content)).toBe(payload.files[0].content);
expect(body.code_coverage_file_content).toBeUndefined();
expect(body.format).toBeUndefined();
expect(headers).toEqual({
Authorization: 'Bearer oidc-jwt',
'Content-Type': 'application/json',
Accept: 'application/json',
});
});

it('posts cobertura files when format is cobertura', async () => {
const xml = '<coverage/>';
await uploadCoverage(samplePayload(xml, 'cobertura'));

const [, rawBody] = mockPost.mock.calls[0];
const body = JSON.parse(rawBody);
expect(body.files[0].format).toBe('cobertura');
expect(decodeCoverageContent(body.files[0].content)).toBe(xml);
});

it('throws with reason_phrase from the JSON body', async () => {
mockPost.mockResolvedValue(
mockResponse(
Expand All @@ -149,39 +170,39 @@ describe('uploadCoverage', () => {
),
);

await expect(uploadCoverage(codeCoverageFileContent)).rejects.toThrow(
await expect(uploadCoverage(samplePayload())).rejects.toThrow(
'Aikido upload failed: Request failed with status code 401 - OIDC token audience mismatch.',
);
});

it('throws with the API message when reason_phrase is absent', async () => {
mockPost.mockResolvedValue(mockResponse(401, JSON.stringify({ message: 'Invalid API key' })));

await expect(uploadCoverage(codeCoverageFileContent)).rejects.toThrow(
await expect(uploadCoverage(samplePayload())).rejects.toThrow(
'Aikido upload failed: Request failed with status code 401 - Invalid API key',
);
});

it('throws with the raw body when JSON has no known error fields', async () => {
mockPost.mockResolvedValue(mockResponse(401, JSON.stringify({ unexpected: true })));

await expect(uploadCoverage(codeCoverageFileContent)).rejects.toThrow(
await expect(uploadCoverage(samplePayload())).rejects.toThrow(
'Aikido upload failed: Request failed with status code 401 - {"unexpected":true}',
);
});

it('throws with the status code when the response body is empty', async () => {
mockPost.mockResolvedValue(mockResponse(401, ''));

await expect(uploadCoverage(codeCoverageFileContent)).rejects.toThrow(
await expect(uploadCoverage(samplePayload())).rejects.toThrow(
'Aikido upload failed: Request failed with status code 401',
);
});

it('throws with the raw body when the response is not JSON', async () => {
mockPost.mockResolvedValue(mockResponse(500, 'Internal server error'));

await expect(uploadCoverage(codeCoverageFileContent)).rejects.toThrow(
await expect(uploadCoverage(samplePayload())).rejects.toThrow(
'Aikido upload failed: Request failed with status code 500 - Internal server error',
);
});
Expand Down
Loading
Loading