Skip to content

Commit 93fa771

Browse files
authored
Support trusted publishers (#138)
1 parent 506bea6 commit 93fa771

File tree

11 files changed

+265
-38
lines changed

11 files changed

+265
-38
lines changed

.github/workflows/check-dist.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ jobs:
2323
steps:
2424
- uses: actions/checkout@v4
2525

26-
- name: Set Node.js 16.x
26+
- name: Set up Node.js
2727
uses: actions/[email protected]
2828
with:
29-
node-version: 16.x
29+
node-version-file: .nvmrc
3030

3131
- name: Install dependencies
3232
run: npm ci

.github/workflows/test.yml

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
npm install
1616
- run: |
1717
npm run all
18-
test: # make sure the action works on a clean machine without building
18+
test-oidc: # make sure the action works on a clean machine without building
1919
runs-on: ubuntu-latest
2020
permissions:
2121
id-token: write
@@ -42,8 +42,32 @@ jobs:
4242
- name: Test token
4343
run: |
4444
curl -v --fail '${{ matrix.gem-server }}/api/v1/oidc/api_key_roles/${{ matrix.roleToken }}' -H 'Authorization: ${{ env.RUBYGEMS_API_KEY }}'
45+
test-trusted-publisher: # make sure the action works on a clean machine without building
46+
runs-on: ubuntu-latest
47+
permissions:
48+
id-token: write
49+
contents: read
50+
strategy:
51+
fail-fast: false
52+
matrix:
53+
gem-server:
54+
- 'rubygems.org'
55+
- 'staging.rubygems.org'
56+
- 'oidc-api-token.rubygems.org'
57+
58+
steps:
59+
- uses: actions/checkout@v4
60+
- uses: ./
61+
with:
62+
gem-server: 'https://${{ matrix.gem-server }}'
63+
audience: '${{ matrix.gem-server }}'
64+
- name: Test token
65+
run: |
66+
output="$(curl -s -w "\n\n%{http_code}" -v -X POST 'https://${{ matrix.gem-server }}/api/v1/gems' -H 'Authorization: ${{ env.RUBYGEMS_API_KEY }}' -H 'Accept: application/json')"
67+
expected="$(printf "RubyGems.org cannot process this gem.\nPlease try rebuilding it and installing it locally to make sure it's valid.\nError:\npackage metadata is missing\n\n\n422")"
68+
test "$output" = "$expected" || (echo "$output" && exit 1)
4569
test-all:
46-
needs: test
70+
needs: [test-oidc, test-trusted-publisher]
4771
runs-on: ubuntu-latest
4872
steps:
4973
- run: |

.nvmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
20

__tests__/main.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import * as core from '@actions/core'
1515

1616
import {configureApiToken} from '../src/configure-api-token'
1717
import {assumeRole} from '../src/oidc/assumeRole'
18+
import {exchangeToken} from '../src/oidc/trustedPublisher'
1819

1920
jest.mock('os', () => {
2021
const originalModule = jest.requireActual('os') as any
@@ -125,6 +126,51 @@ describe('assumeRole', () => {
125126
})
126127
})
127128

129+
describe('exchangeToken', () => {
130+
test('works', async () => {
131+
jest.spyOn(core, 'getIDToken').mockReturnValue(Promise.resolve('ID_TOKEN'))
132+
133+
nock('https://rubygems.org')
134+
.post('/api/v1/oidc/trusted_publisher/exchange_token', {
135+
jwt: 'ID_TOKEN'
136+
})
137+
.reply(201, {
138+
name: 'role name',
139+
rubygems_api_key: 'API_KEY',
140+
expires_at: '2021-01-01T00:00:00Z',
141+
scopes: ['push_rubygem']
142+
})
143+
144+
await expect(
145+
exchangeToken('rubygems.org', 'https://rubygems.org')
146+
).resolves.toEqual({
147+
expiresAt: '2021-01-01T00:00:00Z',
148+
gem: undefined,
149+
name: 'role name',
150+
rubygemsApiKey: 'API_KEY',
151+
scopes: ['push_rubygem']
152+
})
153+
})
154+
155+
test('handles a 404', async () => {
156+
jest.spyOn(core, 'getIDToken').mockReturnValue(Promise.resolve('ID_TOKEN'))
157+
158+
nock('https://rubygems.org')
159+
.post('/api/v1/oidc/trusted_publisher/exchange_token', {
160+
jwt: 'ID_TOKEN'
161+
})
162+
.reply(404, '')
163+
164+
await expect(
165+
exchangeToken('rubygems.org', 'https://rubygems.org')
166+
).rejects.toEqual(
167+
new Error(
168+
'No trusted publisher configured for this workflow found on https://rubygems.org for audience rubygems.org'
169+
)
170+
)
171+
})
172+
})
173+
128174
function mockHomedir(homedir: string) {
129175
mockOf(os.homedir).mockReturnValue(homedir)
130176
}

action.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ inputs:
2222
api-token:
2323
description: 'The rubygems api token to use for authentication.'
2424
required: false
25+
trusted-publisher:
26+
description: >-
27+
Whether to configure the credentials as a trusted publisher. Defaults to true if no other configuration is given.
28+
required: false
2529
runs:
26-
using: 'node16'
30+
using: 'node20'
2731
main: 'dist/index.js'

dist/index.js

Lines changed: 108 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/main.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
11
import * as core from '@actions/core'
22
import {configureApiToken} from './configure-api-token'
33
import {assumeRole} from './oidc/assumeRole'
4+
import {exchangeToken} from './oidc/trustedPublisher'
45

56
async function run(): Promise<void> {
67
try {
78
const gemServer = core.getInput('gem-server')
89
const audience = core.getInput('audience')
910
const roleToAssume = core.getInput('role-to-assume')
1011
const apiToken = core.getInput('api-token')
12+
const trustedPublisher: boolean = (() => {
13+
const trustedPublisherConfigured = !!core.getInput('trusted-publisher')
14+
if (!trustedPublisherConfigured && !apiToken && !roleToAssume) {
15+
// default to trusted publishing if no api-token or role-to-assume is specified and trusted-publisher is not configured
16+
return true
17+
} else if (trustedPublisherConfigured) {
18+
return core.getBooleanInput('trusted-publisher')
19+
} else {
20+
return false
21+
}
22+
})()
1123

1224
if (!gemServer) throw new Error('Missing gem-server input')
1325

@@ -16,13 +28,24 @@ async function run(): Promise<void> {
1628
throw new Error('Cannot specify audience when using api-token')
1729
if (roleToAssume)
1830
throw new Error('Cannot specify role-to-assume when using api-token')
31+
if (trustedPublisher)
32+
throw new Error('Cannot specify trusted-publisher when using api-token')
1933

2034
await configureApiToken(apiToken, gemServer)
2135
} else if (roleToAssume) {
2236
if (!audience) throw new Error('Missing audience input')
37+
if (trustedPublisher)
38+
throw new Error(
39+
'Cannot specify trusted-publisher when using role-to-assume'
40+
)
2341

2442
const oidcIdToken = await assumeRole(roleToAssume, audience, gemServer)
2543
await configureApiToken(oidcIdToken.rubygemsApiKey, gemServer)
44+
} else if (trustedPublisher) {
45+
if (!audience) throw new Error('Missing audience input')
46+
47+
const oidcIdToken = await exchangeToken(audience, gemServer)
48+
await configureApiToken(oidcIdToken.rubygemsApiKey, gemServer)
2649
} else {
2750
throw new Error('Missing api-token or role-to-assume input')
2851
}

src/oidc/assumeRole.ts

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,6 @@
11
import * as core from '@actions/core'
22
import {HttpClient} from '@actions/http-client'
3-
import {z} from 'zod'
4-
5-
const RubygemSchema = z.object({
6-
name: z.string()
7-
})
8-
9-
const IdTokenSchema = z
10-
.object({
11-
rubygems_api_key: z.string(),
12-
name: z.string(),
13-
scopes: z.array(z.string()),
14-
gem: RubygemSchema.optional(),
15-
expires_at: z.string().datetime({offset: true})
16-
})
17-
.transform(({rubygems_api_key, expires_at, ...rest}) => {
18-
return {
19-
rubygemsApiKey: rubygems_api_key,
20-
expiresAt: expires_at,
21-
...rest
22-
}
23-
})
24-
25-
type IdToken = z.infer<typeof IdTokenSchema>
3+
import {IdToken, IdTokenSchema} from './responses'
264

275
export async function assumeRole(
286
roleToAssume: string,

0 commit comments

Comments
 (0)