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
87 changes: 78 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ ___
* [OCI Oracle Cloud Infrastructure Registry (OCIR)](#oci-oracle-cloud-infrastructure-registry-ocir)
* [Quay.io](#quayio)
* [DigitalOcean](#digitalocean-container-registry)
* [Chainguard](#chainguard-registry)
* [Authenticate to multiple registries](#authenticate-to-multiple-registries)
* [Set scopes for the authentication token](#set-scopes-for-the-authentication-token)
* [Customizing](#customizing)
Expand Down Expand Up @@ -496,6 +497,72 @@ jobs:
password: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
```

### Chainguard Registry

To authenticate to the [Chainguard Registry](https://edu.chainguard.dev/chainguard/chainguard-registry/authenticating/)
(`cgr.dev`) using OIDC federation with GitHub Actions, first create a Chainguard
identity scoped to your repository:

```shell
chainctl iam identity create github <identity-name> \
--github-repo=<org>/<repo> \
--github-ref=refs/heads/main \
--role=registry.pull
```

Then use the identity ID in your workflow. The action will automatically exchange
a GitHub Actions OIDC token for a Chainguard registry token:

```yaml
name: ci

on:
push:
branches: main

permissions:
contents: read
id-token: write # Required for OIDC federation

jobs:
login:
runs-on: ubuntu-latest
steps:
-
name: Login to Chainguard Registry
uses: docker/login-action@v4
with:
registry: cgr.dev
chainguard-identity: ${{ secrets.CHAINGUARD_IDENTITY }}
```

> The `id-token: write` permission is required for the GitHub Actions runner to
> request an OIDC token for Chainguard's token exchange.

You can also authenticate using a [pull token](https://edu.chainguard.dev/chainguard/chainguard-registry/authenticating/#authenticating-with-a-pull-token)
with standard username/password login:

```yaml
name: ci

on:
push:
branches: main

jobs:
login:
runs-on: ubuntu-latest
steps:
-
name: Login to Chainguard Registry
uses: docker/login-action@v4
with:
registry: cgr.dev
username: ${{ vars.CHAINGUARD_USERNAME }}
password: ${{ secrets.CHAINGUARD_PULL_TOKEN }}
chainguard: false
```

### Authenticate to multiple registries

To authenticate against multiple registries, you can specify the login-action
Expand Down Expand Up @@ -618,15 +685,17 @@ credentials, while authenticated access is used only to push `myorg/myimage`.

The following inputs can be used as `step.with` keys:

| Name | Type | Default | Description |
|-----------------|--------|-------------|-------------------------------------------------------------------------------|
| `registry` | String | `docker.io` | Server address of Docker registry. If not set then will default to Docker Hub |
| `username` | String | | Username for authenticating to the Docker registry |
| `password` | String | | Password or personal access token for authenticating the Docker registry |
| `scope` | String | | Scope for the authentication token |
| `ecr` | String | `auto` | Specifies whether the given registry is ECR (`auto`, `true` or `false`) |
| `logout` | Bool | `true` | Log out from the Docker registry at the end of a job |
| `registry-auth` | YAML | | Raw authentication to registries, defined as YAML objects |
| Name | Type | Default | Description |
|------------------------|--------|-------------|-------------------------------------------------------------------------------|
| `registry` | String | `docker.io` | Server address of Docker registry. If not set then will default to Docker Hub |
| `username` | String | | Username for authenticating to the Docker registry |
| `password` | String | | Password or personal access token for authenticating the Docker registry |
| `scope` | String | | Scope for the authentication token |
| `ecr` | String | `auto` | Specifies whether the given registry is ECR (`auto`, `true` or `false`) |
| `chainguard` | String | `auto` | Specifies whether the given registry is Chainguard (`auto`, `true` or `false`)|
| `chainguard-identity` | String | | Chainguard identity to assume for OIDC-based authentication |
| `logout` | Bool | `true` | Log out from the Docker registry at the end of a job |
| `registry-auth` | YAML | | Raw authentication to registries, defined as YAML objects |

> [!NOTE]
> The `registry-auth` input cannot be used with other inputs except `logout`.
Expand Down
99 changes: 99 additions & 0 deletions __tests__/chainguard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import {beforeEach, describe, expect, test, vi} from 'vitest';

import * as chainguard from '../src/chainguard.js';

describe('isChainguard', () => {
test.each([
['cgr.dev', true],
['registry.gitlab.com', false],
['gcr.io', false],
['docker.io', false],
['ghcr.io', false],
['public.ecr.aws', false],
['012345678901.dkr.ecr.eu-west-3.amazonaws.com', false],
['not-cgr.dev', false],
['cgr.dev.example.com', false]
])('given registry %p returns %p', (registry, expected) => {
expect(chainguard.isChainguard(registry)).toEqual(expected);
});
});

const mockGetIDToken = vi.fn();
vi.mock('@actions/core', () => ({
info: vi.fn(),
setSecret: vi.fn(),
getIDToken: (...args: unknown[]) => mockGetIDToken(...args)
}));

const mockGetJson = vi.fn();
vi.mock('@actions/http-client', () => {
return {
HttpClient: class {
getJson = mockGetJson;
}
};
});

describe('getRegistryToken', () => {
beforeEach(() => {
vi.clearAllMocks();
});

test('exchanges OIDC token for Chainguard token', async () => {
const fakeOIDCToken = 'oidc-token-123';
const fakeChainguardToken = 'chainguard-token-456';
const identity = 'abc123/def456';

mockGetIDToken.mockResolvedValue(fakeOIDCToken);
mockGetJson.mockResolvedValue({
statusCode: 200,
result: {token: fakeChainguardToken}
});

const result = await chainguard.getRegistryToken(identity);

expect(mockGetIDToken).toHaveBeenCalledWith('cgr.dev');
expect(mockGetJson).toHaveBeenCalledWith(`https://issuer.enforce.dev/sts/exchange?aud=cgr.dev&identity=${encodeURIComponent(identity)}`, {Authorization: `Bearer ${fakeOIDCToken}`});
expect(result).toEqual({
username: 'user',
password: fakeChainguardToken
});
});

test('uses custom issuer URL when provided', async () => {
const fakeOIDCToken = 'oidc-token-123';
const fakeChainguardToken = 'chainguard-token-456';
const identity = 'abc123/def456';
const customIssuer = 'https://custom-issuer.example.dev';

mockGetIDToken.mockResolvedValue(fakeOIDCToken);
mockGetJson.mockResolvedValue({
statusCode: 200,
result: {token: fakeChainguardToken}
});

await chainguard.getRegistryToken(identity, customIssuer);

expect(mockGetJson).toHaveBeenCalledWith(`${customIssuer}/sts/exchange?aud=cgr.dev&identity=${encodeURIComponent(identity)}`, {Authorization: `Bearer ${fakeOIDCToken}`});
});

test('throws on non-200 response', async () => {
mockGetIDToken.mockResolvedValue('oidc-token');
mockGetJson.mockResolvedValue({
statusCode: 401,
result: null
});

await expect(chainguard.getRegistryToken('identity-id')).rejects.toThrow('Failed to exchange OIDC token with Chainguard (HTTP 401)');
});

test('throws when response has no token', async () => {
mockGetIDToken.mockResolvedValue('oidc-token');
mockGetJson.mockResolvedValue({
statusCode: 200,
result: {}
});

await expect(chainguard.getRegistryToken('identity-id')).rejects.toThrow('Failed to exchange OIDC token with Chainguard (HTTP 200)');
});
});
6 changes: 6 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ inputs:
ecr:
description: 'Specifies whether the given registry is ECR (auto, true or false)'
required: false
chainguard:
description: 'Specifies whether the given registry is Chainguard (auto, true or false)'
required: false
chainguard-identity:
description: 'Chainguard identity to assume for OIDC-based authentication'
required: false
scope:
description: 'Scope for the authentication token'
required: false
Expand Down
Loading