Skip to content
Merged
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
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ Use this entry point when you need to process or manipulate reports in browser-b
### Building Reports
- **`CIUtils`** - Utilities to extract CI/CD information (run URLs, run titles, environment detection)
- **`GithubOIDC`** - GitHub Actions OIDC integration for passwordless Flakiness.io authentication
- **`GitlabOIDC`** - GitLab CI/CD OIDC integration for passwordless Flakiness.io authentication
- **`initializeOIDCFromEnv()`** - Detect the OIDC provider for the current CI environment
- **`GitWorktree`** - Git repository utilities for path conversion and commit information
- **`ReportUtils`** - Namespace with utilities for report creation and manipulation:
- `createEnvironment()` - Create environment objects with system information
Expand Down Expand Up @@ -127,6 +129,7 @@ Authentication follows the same priority order as `uploadReport()`:

1. **Access token** — pass `flakinessAccessToken` option or set the `FLAKINESS_ACCESS_TOKEN` environment variable.
2. **GitHub Actions OIDC** — when running inside GitHub Actions and the report has `flakinessProject` set.
3. **GitLab CI/CD OIDC** — when running inside GitLab CI/CD with a `FLAKINESS_ID_TOKEN` ID token and the report has `flakinessProject` set.

## Uploading Reports

Expand All @@ -152,5 +155,42 @@ Authentication follows the same priority order as `uploadReport()`:
// No access token needed — OIDC authentication is used automatically.
await uploadReport(report, attachments);
```
3. **GitLab CI/CD OIDC** — when running inside GitLab CI/CD, `uploadReport` can authenticate automatically without an access token. This works when these conditions are met:
- The report has `flakinessProject` set to a flakiness project identifier (e.g. `"org/proj"`).
- The flakiness project is bound to the GitLab project that runs the pipeline.
- The job declares a `FLAKINESS_ID_TOKEN` ID token whose audience is that same project identifier.

GitLab mints ID tokens when the job starts, so — unlike GitHub Actions, where the SDK picks the
audience at runtime — the audience is declared in `.gitlab-ci.yml`:

```yaml
test:
id_tokens:
FLAKINESS_ID_TOKEN:
aud: my-org/my-project
script:
- npm test
```

`aud` expands CI/CD variables (GitLab 16.1+), so a shared pipeline template can use
`aud: $FLAKINESS_PROJECT` and let each project set that variable in its CI/CD settings.

If none of these methods is available, the upload is skipped with a `'skipped'` status.

### Resolving OIDC credentials directly

`uploadReport()` and `fetchTestDurations()` do this for you. Tools that manage credentials
themselves can run the same detection with `initializeOIDCFromEnv()`, which returns the provider
for the current CI environment (GitHub Actions first, then GitLab CI/CD) or `undefined`:

```typescript
import { initializeOIDCFromEnv } from '@flakiness/sdk';

const oidc = initializeOIDCFromEnv();
if (oidc) {
console.log(`Authenticating via ${oidc.name} OIDC`);
const flakinessAccessToken = await oidc.createFlakinessAccessToken('my-org/my-project');
}
```

If neither method is available, the upload is skipped with a `'skipped'` status.
`oidc.name` is for humans; use `oidc instanceof GithubOIDC` to branch on the provider.
19 changes: 11 additions & 8 deletions src/fetchTestDurations.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { FlakinessReport } from '@flakiness/flakiness-report';
import { URL } from 'url';
import { compressTextAsync, getJSON, putBuffer, sha1Text } from './_internalUtils.js';
import { GithubOIDC } from './githubOIDC.js';
import { initializeOIDCFromEnv } from './oidc.js';

type TestDurationsFetcherOptions = {
flakinessEndpoint: string;
Expand Down Expand Up @@ -32,8 +32,8 @@ export type FetchTestDurationsOptions = {
* Access token for authenticating with the Flakiness.io platform.
*
* Defaults to the `FLAKINESS_ACCESS_TOKEN` environment variable. If no token is provided
* through this option or the environment variable, the function attempts GitHub Actions OIDC
* when running in GitHub Actions (requires `report.flakinessProject` to be set and the project
* through this option or the environment variable, the function attempts CI OIDC when running
* in GitHub Actions or GitLab CI/CD (requires `report.flakinessProject` to be set and the project
* to be bound to the repository). If no token can be obtained, durations are fetched anonymously
* using `report.flakinessProject`, which the server only allows for public projects.
*
Expand All @@ -54,7 +54,7 @@ const DOWNLOAD_BACKOFF = [Array(10).fill(1000), Array(10).fill(2000), Array(20).
* finishes at roughly the same time.
*
* The function performs the following steps:
* 1. Resolves credentials: an access token, GitHub Actions OIDC, or anonymous access
* 1. Resolves credentials: an access token, CI OIDC, or anonymous access
* for public projects.
* 2. Computes a shard-group key from the report so that all shards of the same run
* fetch an identical set of timings.
Expand All @@ -68,7 +68,10 @@ const DOWNLOAD_BACKOFF = [Array(10).fill(1000), Array(10).fill(2000), Array(20).
* 1. **Access token** — provided via `flakinessAccessToken` option or `FLAKINESS_ACCESS_TOKEN` env var.
* 2. **GitHub Actions OIDC** — when running in GitHub Actions with no access token. This requires
* `report.flakinessProject` to be set and the project to be bound to the GitHub repository.
* 3. **Anonymous** — when no token can be obtained but `report.flakinessProject` is set. The request
* 3. **GitLab CI/CD OIDC** — when running in GitLab CI/CD with no access token. This requires
* `report.flakinessProject` to be set, the project to be bound to the GitLab project, and the job
* to declare a `FLAKINESS_ID_TOKEN` ID token with a matching `aud` (see {@link GitlabOIDC}).
* 4. **Anonymous** — when no token can be obtained but `report.flakinessProject` is set. The request
* names the project via that field and sends no credentials. The server only honors this for public
* projects, which covers pull requests from forks: GitHub denies them both repository secrets and an
* OIDC token. Private projects are rejected by the server.
Expand All @@ -92,9 +95,9 @@ export async function fetchTestDurations(
): Promise<FlakinessReport.Report> {
let flakinessAccessToken = options?.flakinessAccessToken ?? process.env['FLAKINESS_ACCESS_TOKEN'];

const githubOIDC = GithubOIDC.initializeFromEnv();
if (!flakinessAccessToken && githubOIDC && report.flakinessProject)
flakinessAccessToken = await githubOIDC.createFlakinessAccessToken(report.flakinessProject);
const oidc = initializeOIDCFromEnv();
if (!flakinessAccessToken && oidc && report.flakinessProject)
flakinessAccessToken = await oidc.createFlakinessAccessToken(report.flakinessProject);

const flakinessEndpoint = options?.flakinessEndpoint ?? process.env['FLAKINESS_ENDPOINT'] ?? 'https://flakiness.io';

Expand Down
11 changes: 10 additions & 1 deletion src/githubOIDC.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getJSON } from './_internalUtils.js';
import type { OIDCProvider } from './oidc.js';

/**
* Provides GitHub Actions OIDC (OpenID Connect) token exchange.
Expand All @@ -17,7 +18,7 @@ import { getJSON } from './_internalUtils.js';
* }
* ```
*/
export class GithubOIDC {
export class GithubOIDC implements OIDCProvider {
/**
* Creates a GithubOIDC instance from GitHub Actions environment variables.
*
Expand All @@ -33,6 +34,14 @@ export class GithubOIDC {
return requestUrl && requestToken ? new GithubOIDC(requestUrl, requestToken) : undefined;
}

/**
* Human-readable name of the CI provider, suitable for log messages.
*
* To branch on the provider, use `oidc instanceof GithubOIDC` instead — this string is meant
* for humans and may be reworded.
*/
readonly name = 'GitHub Actions';

constructor(
private _requestUrl: string,
private _requestToken: string,
Expand Down
136 changes: 136 additions & 0 deletions src/gitlabOIDC.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import type { OIDCProvider } from './oidc.js';

/**
* Provides GitLab CI/CD OIDC (OpenID Connect) authentication.
*
* Enables passwordless authentication with Flakiness.io from GitLab CI/CD pipelines
* using GitLab ID tokens. Used internally by {@link uploadReport} for automatic
* authentication, but can also be used directly.
*
* Unlike GitHub Actions — where the SDK mints an OIDC token at runtime and picks the
* `aud` claim itself — GitLab mints ID tokens when the job starts and exposes them as
* environment variables. The audience is therefore declared in `.gitlab-ci.yml` and must
* match the flakiness project the report is uploaded to:
*
* ```yaml
* test:
* id_tokens:
* FLAKINESS_ID_TOKEN:
* aud: my-org/my-project
* script:
* - npm test
* ```
*
* `aud` expands CI/CD variables (GitLab 16.1+), so a shared pipeline template can use
* `aud: $FLAKINESS_PROJECT`.
*
* @example
* ```typescript
* const oidc = GitlabOIDC.initializeFromEnv();
* if (oidc) {
* const token = await oidc.createFlakinessAccessToken('my-org/my-project');
* }
* ```
*/
export class GitlabOIDC implements OIDCProvider {
/**
* Creates a GitlabOIDC instance from GitLab CI/CD environment variables.
*
* Reads the `FLAKINESS_ID_TOKEN` environment variable, which GitLab CI/CD sets for jobs
* that declare an `id_tokens: FLAKINESS_ID_TOKEN:` entry in `.gitlab-ci.yml`.
*
* @returns {GitlabOIDC | undefined} A GitlabOIDC instance if the environment variable is
* present, or `undefined` if not running in GitLab CI/CD with an ID token configured.
*/
static initializeFromEnv(): GitlabOIDC|undefined {
const idToken = process.env.FLAKINESS_ID_TOKEN;
return idToken ? new GitlabOIDC(idToken) : undefined;
}

/**
* Human-readable name of the CI provider, suitable for log messages.
*
* To branch on the provider, use `oidc instanceof GitlabOIDC` instead — this string is meant
* for humans and may be reworded.
*/
readonly name = 'GitLab CI/CD';

constructor(
private _idToken: string,
) {

}

/**
* Returns the Flakiness access token for the specified project — the GitLab ID token itself.
*
* This method succeeds as long as the ID token names `flakinessProject` in its `aud` claim.
* However, the returned token can only be used to upload reports if the Flakiness.io project
* is bound to the GitLab project running the pipeline. If the project is not bound,
* Flakiness.io will reject the token on upload.
*
* @param {string} flakinessProject - The flakiness project identifier in `"org/project"` format.
*
* @returns {Promise<string>} A Flakiness access token.
*
* @throws {Error} If the ID token is not a JWT, carries no `aud` claim, or its `aud` claim
* does not include `flakinessProject`. GitLab mints the token when the job starts, so all
* three can only be fixed in `.gitlab-ci.yml`.
*/
async createFlakinessAccessToken(flakinessProject: string) {
// Every check below is a `.gitlab-ci.yml` misconfiguration that cannot be fixed at runtime
// and that the server would reject anyway, so failing here with a precise message beats
// letting the upload come back as a bare 401.
const payload = jwtPayload(this._idToken);
if (!payload) {
throw new Error([
`GitLab ID token is not a JWT.`,
`Check that FLAKINESS_ID_TOKEN comes from an id_tokens entry with \`aud: ${flakinessProject}\` in .gitlab-ci.yml.`,
].join(' '));
}

const audience = audienceClaim(payload);
if (!audience.length) {
throw new Error([
`GitLab ID token has no audience, so it cannot upload to "${flakinessProject}".`,
`Declare the FLAKINESS_ID_TOKEN id_token with \`aud: ${flakinessProject}\` in .gitlab-ci.yml.`,
].join(' '));
}
if (!audience.includes(flakinessProject)) {
throw new Error([
`GitLab ID token audience is ${audience.map(aud => JSON.stringify(aud)).join(', ')}, but the report uploads to "${flakinessProject}".`,
`Set the audience of the FLAKINESS_ID_TOKEN id_token in .gitlab-ci.yml to "${flakinessProject}".`,
].join(' '));
}
return this._idToken;
}
}

/**
* Reads a JWT payload without verifying the signature; the Flakiness.io server is the one that
* verifies the token. Returns `undefined` if the token is not a JWT.
*/
function jwtPayload(jwt: string): Record<string, unknown>|undefined {
const payload = jwt.split('.')[1];
if (!payload)
Comment thread
aslushnikov marked this conversation as resolved.
return undefined;
try {
const json = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
return json && typeof json === 'object' && !Array.isArray(json) ? json : undefined;
} catch {
return undefined;
}
}

/**
* Normalizes the `aud` claim, which a JWT may carry as either a string or an array of strings,
* into a list. Returns an empty list when the claim is absent or unusable.
*/
function audienceClaim(payload: Record<string, unknown>): string[] {
const aud = payload['aud'];
if (typeof aud === 'string')
return [aud];
if (Array.isArray(aud))
return aud.filter(entry => typeof entry === 'string');
return [];
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export { CPUUtilization } from './cpuUtilization.js';
export { GitWorktree, type GitWorktreeInitResult } from './gitWorktree.js';
export { RAMUtilization } from './ramUtilization.js';
export { GithubOIDC } from './githubOIDC.js';
export { GitlabOIDC } from './gitlabOIDC.js';
export { initializeOIDCFromEnv, type OIDCProvider } from './oidc.js';
export * as ReportUtils from './reportUtils.js';

// Working with reports
Expand Down
47 changes: 47 additions & 0 deletions src/oidc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { GithubOIDC } from './githubOIDC.js';
import { GitlabOIDC } from './gitlabOIDC.js';

/**
* A CI provider that can mint a Flakiness access token without a stored secret.
*
* Implemented by {@link GithubOIDC} and {@link GitlabOIDC}. Use `instanceof` to tell them apart.
*/
export type OIDCProvider = {
/** Human-readable name of the CI provider, suitable for log messages. */
readonly name: string;

/**
* Mints a Flakiness access token for the specified project.
*
* @param {string} flakinessProject - The flakiness project identifier in `"org/project"` format.
* @returns {Promise<string>} A Flakiness access token.
*/
createFlakinessAccessToken(flakinessProject: string): Promise<string>;
}

/**
* Detects the OIDC provider for the current CI environment.
*
* Both providers hand out a token that *is* the credential: an OIDC JWT whose `aud` claim names
* the flakiness project, which Flakiness.io verifies against the CI provider. This is the
* detection {@link uploadReport} and {@link fetchTestDurations} use when no access token is
* configured, exposed for tools that resolve credentials themselves.
*
* Providers are checked in order: GitHub Actions ({@link GithubOIDC}), then GitLab CI/CD
* ({@link GitlabOIDC}).
*
* @returns {OIDCProvider | undefined} A provider for the current environment, or `undefined` when
* no CI OIDC credentials are available.
*
* @example
* ```typescript
* const oidc = initializeOIDCFromEnv();
* if (oidc) {
* console.log(`Authenticating via ${oidc.name} OIDC`);
* const token = await oidc.createFlakinessAccessToken('my-org/my-project');
* }
* ```
*/
export function initializeOIDCFromEnv(): OIDCProvider|undefined {
return GithubOIDC.initializeFromEnv() ?? GitlabOIDC.initializeFromEnv();
}
Loading