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
73 changes: 73 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ instructions][generating-cred-bundle] and provided as the `creds` input.
> they do not need to be provided unless the token is needed for a different
> repository.

The minted token carries the App installation's full set of permissions unless
you narrow it via the optional `permissions` input (see
[Narrowing token permissions](#narrowing-token-permissions)).

### Example

```yaml
Expand Down Expand Up @@ -50,11 +54,76 @@ jobs:
- `repo` - _(optional)_ The repository name for a repo-scoped token
- `export-git-user` - _(optional)_ [Export environment
variables][git-env-variables] which set the Git user to the GitHub app user
- `permissions` - _(optional)_ YAML mapping used to narrow the minted
installation token to a subset of the App's permissions (see
[Narrowing token permissions](#narrowing-token-permissions))

### Outputs

- `token` - GitHub App installation access token

### Narrowing token permissions

By default, the action mints an installation token with every permission the App
installation has been granted. The `permissions` input accepts a YAML mapping of
`<permission>: <level>` entries (same shape GitHub uses in workflow
`permissions:` blocks) and forwards them to the
[`POST /app/installations/{id}/access_tokens`][create-access-token] endpoint so
the minted token only carries the requested subset.

Each level must be one of `read`, `write`, or `admin`. Permissions listed here
must be a subset of the App's installation permissions — you cannot escalate
beyond what the App itself was granted. Omitting the input (or leaving it empty)
preserves the previous behavior of minting a full-scope token.

This is useful for privilege-splitting a workflow across phases: mint a
read-only token for a checkout or scan step, revoke it, and only mint a
write-scoped token immediately before a push.

```yaml
jobs:
roll:
runs-on: ubuntu-latest
steps:
- name: Mint read-only token for checkout
id: read-token
uses: electron/github-app-auth-action@v2
with:
creds: ${{ secrets.GH_APP_CREDS }}
owner: my-org
repo: my-repo
permissions: |
contents: read

- uses: actions/checkout@v6
with:
repository: my-org/my-repo
token: ${{ steps.read-token.outputs.token }}

# ... do work that does not need write access ...

- name: Mint write-scoped token for push
id: write-token
uses: electron/github-app-auth-action@v2
with:
creds: ${{ secrets.GH_APP_CREDS }}
owner: my-org
repo: my-repo
permissions: |
contents: write

- name: Push
env:
GITHUB_TOKEN: ${{ steps.write-token.outputs.token }}
run: |
git push \
"https://x-access-token:${GITHUB_TOKEN}@github.com/my-org/my-repo.git" \
HEAD:refs/heads/some-branch
```

Refer to the [GitHub REST API permission reference][permissions-reference] for
the full list of permission names and levels.

## License

MIT
Expand All @@ -63,3 +132,7 @@ MIT
https://github.com/electron/github-app-auth#generating-credentials
[git-env-variables]:
https://git-scm.com/book/en/v2/Git-Internals-Environment-Variables
[create-access-token]:
https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app
[permissions-reference]:
https://docs.github.com/en/rest/overview/permissions-required-for-github-apps
162 changes: 159 additions & 3 deletions __tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ describe('action', () => {
expect(getTokenForRepo).toHaveBeenCalledTimes(1);
expect(getTokenForRepo).toHaveBeenLastCalledWith(
{ owner: 'electron', name: 'electron' },
expect.anything()
expect.anything(),
{}
);

// Marks the token as a secret
Expand Down Expand Up @@ -166,7 +167,8 @@ describe('action', () => {
expect(getTokenForRepo).toHaveBeenCalledTimes(1);
expect(getTokenForRepo).toHaveBeenLastCalledWith(
{ owner: 'electron', name: 'fake-repo' },
expect.anything()
expect.anything(),
{}
);

// Marks the token as a secret
Expand Down Expand Up @@ -245,7 +247,8 @@ describe('action', () => {
expect(getTokenForOrg).toHaveBeenCalledTimes(1);
expect(getTokenForOrg).toHaveBeenLastCalledWith(
'electron',
expect.anything()
expect.anything(),
{}
);

// Marks the token as a secret
Expand Down Expand Up @@ -342,4 +345,157 @@ describe('action', () => {
expect(core.setFailed).toHaveBeenCalledTimes(1);
expect(core.setFailed).toHaveBeenLastCalledWith(message);
});

it('narrows permissions on a repo token', async () => {
const token = 'repo-token';
vi.mocked(core.getInput).mockImplementation((name: string) => {
switch (name) {
case 'creds':
return 'foobar';
case 'owner':
return 'electron';
case 'repo':
return 'fake-repo';
case 'permissions':
return 'contents: read\nissues: write\n';
default:
return '';
}
});
vi.mocked(getTokenForRepo).mockResolvedValue(token);

await index.run();
expect(runSpy).toHaveReturned();

expect(getTokenForRepo).toHaveBeenLastCalledWith(
{ owner: 'electron', name: 'fake-repo' },
expect.anything(),
{ permissions: { contents: 'read', issues: 'write' } }
);
});

it('narrows permissions on an org token', async () => {
const token = 'org-token';
vi.mocked(core.getInput).mockImplementation((name: string) => {
switch (name) {
case 'creds':
return 'foobar';
case 'org':
return 'electron';
case 'permissions':
return 'contents: read';
default:
return '';
}
});
vi.mocked(getTokenForOrg).mockResolvedValue(token);

await index.run();
expect(runSpy).toHaveReturned();

expect(getTokenForOrg).toHaveBeenLastCalledWith(
'electron',
expect.anything(),
{
permissions: { contents: 'read' }
}
);
});

it('rejects invalid permission levels', async () => {
vi.mocked(core.getInput).mockImplementation((name: string) => {
switch (name) {
case 'creds':
return 'foobar';
case 'org':
return 'electron';
case 'permissions':
return 'contents: bogus';
default:
return '';
}
});

await index.run();
expect(runSpy).toHaveReturned();

expect(core.setFailed).toHaveBeenCalledTimes(1);
expect(core.setFailed).toHaveBeenLastCalledWith(
expect.stringContaining('Invalid permission level')
);
expect(getTokenForOrg).not.toHaveBeenCalled();
});

it('rejects non-mapping permissions input', async () => {
vi.mocked(core.getInput).mockImplementation((name: string) => {
switch (name) {
case 'creds':
return 'foobar';
case 'org':
return 'electron';
case 'permissions':
// A bare scalar parses to a string, which isn't a mapping.
return 'just-a-scalar';
default:
return '';
}
});

await index.run();
expect(runSpy).toHaveReturned();

expect(core.setFailed).toHaveBeenCalledTimes(1);
expect(core.setFailed).toHaveBeenLastCalledWith(
expect.stringContaining('YAML mapping')
);
expect(getTokenForOrg).not.toHaveBeenCalled();
});
});

describe('parsePermissionsInput', () => {
it('returns undefined for empty input', () => {
expect(index.parsePermissionsInput('')).toBeUndefined();
expect(index.parsePermissionsInput(' \n ')).toBeUndefined();
});

it('parses a single permission', () => {
expect(index.parsePermissionsInput('contents: read')).toEqual({
contents: 'read'
});
});

it('parses multiple permissions across newlines', () => {
expect(
index.parsePermissionsInput('contents: read\nissues: write\n')
).toEqual({ contents: 'read', issues: 'write' });
});

it('ignores blank lines and YAML comments', () => {
expect(
index.parsePermissionsInput(
'# leading comment\n\ncontents: read # trailing\n'
)
).toEqual({ contents: 'read' });
});

it('throws on invalid level', () => {
expect(() => index.parsePermissionsInput('contents: oops')).toThrow(
/Invalid permission level/
);
});

it('throws when the document is not a mapping', () => {
expect(() => index.parsePermissionsInput('just-a-scalar')).toThrow(
/YAML mapping/
);
expect(() => index.parsePermissionsInput('- contents: read')).toThrow(
/YAML mapping/
);
});

it('throws on YAML syntax errors', () => {
expect(() =>
index.parsePermissionsInput('contents: read\n bad: [')
).toThrow(/Could not parse permissions as YAML/);
});
});
32 changes: 32 additions & 0 deletions __tests__/post.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,36 @@ describe('post', () => {
`Error while revoking token: ${error.message}`
);
});

it('treats 401 as an already-revoked no-op', async () => {
const token = 'gha_token';
const error = Object.assign(new Error('Bad credentials'), { status: 401 });
vi.mocked(core.getState).mockReturnValue(token);
vi.mocked(revokeInstallationAccessToken).mockRejectedValue(error);

await post.post();
expect(postSpy).toHaveReturned();

expect(github.getOctokit).toHaveBeenCalledWith(token);
expect(core.info).toHaveBeenCalledWith('Token was already revoked');
expect(core.warning).not.toHaveBeenCalled();
});

it('awaits the revocation promise', async () => {
const token = 'gha_token';
let resolved = false;
vi.mocked(core.getState).mockReturnValue(token);
vi.mocked(revokeInstallationAccessToken).mockImplementation(
async () =>
await new Promise<void>(resolve =>
setTimeout(() => {
resolved = true;
resolve();
}, 0)
)
);

await post.post();
expect(resolved).toBe(true);
});
});
12 changes: 12 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ inputs:
user'
required: false
default: false
permissions:
description: |
Optional permission narrowing for the minted installation token.
Newline-delimited `<permission>: <level>` pairs, where level is
read | write | admin. Permissions listed here must be a subset of
the App's installation permissions; unlisted permissions are not
granted to the token. Example:
permissions: |
contents: read
issues: write
required: false
default: ''

outputs:
token:
Expand Down
Loading
Loading