Skip to content

Commit 7bc649b

Browse files
authored
add implementation of permissions inputs (#217)
1 parent 3674124 commit 7bc649b

18 files changed

+279
-86
lines changed

CONTRIBUTING.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Contributing
2+
3+
Initial setup
4+
5+
```console
6+
npm install
7+
```
8+
9+
Run tests locally
10+
11+
```console
12+
npm test
13+
```
14+
15+
Learn more about how the tests work in [test/README.md](test/README.md).

README.md

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ jobs:
121121

122122
> [!TIP]
123123
> The `<BOT USER ID>` is the numeric user ID of the app's bot user, which can be found under `https://api.github.com/users/<app-slug>%5Bbot%5D`.
124-
>
124+
>
125125
> For example, we can check at `https://api.github.com/users/dependabot[bot]` to see the user ID of Dependabot is 49699333.
126126
>
127127
> Alternatively, you can use the [octokit/request-action](https://github.com/octokit/request-action) to get the ID.
@@ -195,6 +195,32 @@ jobs:
195195
body: "Hello, World!"
196196
```
197197

198+
### Create a token with specific permissions
199+
200+
> [!NOTE]
201+
> Selected permissions must be granted to the installation of the specified app and repository owner. Setting a permission that the installation does not have will result in an error.
202+
203+
```yaml
204+
on: [issues]
205+
206+
jobs:
207+
hello-world:
208+
runs-on: ubuntu-latest
209+
steps:
210+
- uses: actions/create-github-app-token@v1
211+
id: app-token
212+
with:
213+
app-id: ${{ vars.APP_ID }}
214+
private-key: ${{ secrets.PRIVATE_KEY }}
215+
owner: ${{ github.repository_owner }}
216+
permission-issues: write
217+
- uses: peter-evans/create-or-update-comment@v3
218+
with:
219+
token: ${{ steps.app-token.outputs.token }}
220+
issue-number: ${{ github.event.issue.number }}
221+
body: "Hello, World!"
222+
```
223+
198224
### Create tokens for multiple user or organization accounts
199225

200226
You can use a matrix strategy to create tokens for multiple user or organization accounts.
@@ -251,23 +277,23 @@ jobs:
251277
runs-on: self-hosted
252278
253279
steps:
254-
- name: Create GitHub App token
255-
id: create_token
256-
uses: actions/create-github-app-token@v1
257-
with:
258-
app-id: ${{ vars.GHES_APP_ID }}
259-
private-key: ${{ secrets.GHES_APP_PRIVATE_KEY }}
260-
owner: ${{ vars.GHES_INSTALLATION_ORG }}
261-
github-api-url: ${{ vars.GITHUB_API_URL }}
262-
263-
- name: Create issue
264-
uses: octokit/[email protected]
265-
with:
266-
route: POST /repos/${{ github.repository }}/issues
267-
title: "New issue from workflow"
268-
body: "This is a new issue created from a GitHub Action workflow."
269-
env:
270-
GITHUB_TOKEN: ${{ steps.create_token.outputs.token }}
280+
- name: Create GitHub App token
281+
id: create_token
282+
uses: actions/create-github-app-token@v1
283+
with:
284+
app-id: ${{ vars.GHES_APP_ID }}
285+
private-key: ${{ secrets.GHES_APP_PRIVATE_KEY }}
286+
owner: ${{ vars.GHES_INSTALLATION_ORG }}
287+
github-api-url: ${{ vars.GITHUB_API_URL }}
288+
289+
- name: Create issue
290+
uses: octokit/[email protected]
291+
with:
292+
route: POST /repos/${{ github.repository }}/issues
293+
title: "New issue from workflow"
294+
body: "This is a new issue created from a GitHub Action workflow."
295+
env:
296+
GITHUB_TOKEN: ${{ steps.create_token.outputs.token }}
271297
```
272298

273299
## Inputs
@@ -309,6 +335,12 @@ steps:
309335
> [!NOTE]
310336
> If `owner` is set and `repositories` is empty, access will be scoped to all repositories in the provided repository owner's installation. If `owner` and `repositories` are empty, access will be scoped to only the current repository.
311337

338+
### `permission-<permission name>`
339+
340+
**Optional:** The permissions to grant to the token. By default, the token inherits all of the installation's permissions. We recommend to explicitly list the permissions that are required for a use case. This follows GitHub's own recommendation to [control permissions of `GITHUB_TOKEN` in workflows](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/controlling-permissions-for-github_token). The documentation also lists all available permissions, just prefix the permission key with `permission-` (e.g., `pull-requests` → `permission-pull-requests`).
341+
342+
The reason we define one `permision-<permission name>` input per permission is to benefit from type intelligence and input validation built into GitHub's action runner.
343+
312344
### `skip-token-revoke`
313345

314346
**Optional:** If truthy, the token will not be revoked when the current job is complete.
@@ -344,6 +376,10 @@ The action creates an installation access token using [the `POST /app/installati
344376
> [!NOTE]
345377
> Installation permissions can differ from the app's permissions they belong to. Installation permissions are set when an app is installed on an account. When the app adds more permissions after the installation, an account administrator will have to approve the new permissions before they are set on the installation.
346378

379+
## Contributing
380+
381+
[CONTRIBUTING.md](CONTRIBUTING.md)
382+
347383
## License
348384

349385
[MIT](LICENSE)

lib/get-permissions-from-inputs.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Finds all permissions passed via `permision-*` inputs and turns them into an object.
3+
*
4+
* @see https://docs.github.com/en/actions/sharing-automations/creating-actions/metadata-syntax-for-github-actions#inputs
5+
* @param {NodeJS.ProcessEnv} env
6+
* @returns {undefined | Record<string, string>}
7+
*/
8+
export function getPermissionsFromInputs(env) {
9+
return Object.entries(env).reduce((permissions, [key, value]) => {
10+
if (!key.startsWith("INPUT_PERMISSION_")) return permissions;
11+
12+
const permission = key.slice("INPUT_PERMISSION_".length).toLowerCase();
13+
if (permissions === undefined) {
14+
return { [permission]: value };
15+
}
16+
17+
return {
18+
// @ts-expect-error - needs to be typed correctly
19+
...permissions,
20+
[permission]: value,
21+
};
22+
}, undefined);
23+
}

lib/main.js

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import pRetry from "p-retry";
66
* @param {string} privateKey
77
* @param {string} owner
88
* @param {string[]} repositories
9+
* @param {undefined | Record<string, string>} permissions
910
* @param {import("@actions/core")} core
1011
* @param {import("@octokit/auth-app").createAppAuth} createAppAuth
1112
* @param {import("@octokit/request").request} request
@@ -16,10 +17,11 @@ export async function main(
1617
privateKey,
1718
owner,
1819
repositories,
20+
permissions,
1921
core,
2022
createAppAuth,
2123
request,
22-
skipTokenRevoke
24+
skipTokenRevoke,
2325
) {
2426
let parsedOwner = "";
2527
let parsedRepositoryNames = [];
@@ -31,7 +33,7 @@ export async function main(
3133
parsedRepositoryNames = [repo];
3234

3335
core.info(
34-
`owner and repositories not set, creating token for the current repository ("${repo}")`
36+
`owner and repositories not set, creating token for the current repository ("${repo}")`,
3537
);
3638
}
3739

@@ -40,7 +42,7 @@ export async function main(
4042
parsedOwner = owner;
4143

4244
core.info(
43-
`repositories not set, creating token for all repositories for given owner "${owner}"`
45+
`repositories not set, creating token for all repositories for given owner "${owner}"`,
4446
);
4547
}
4648

@@ -51,8 +53,8 @@ export async function main(
5153

5254
core.info(
5355
`owner not set, creating owner for given repositories "${repositories.join(
54-
","
55-
)}" in current owner ("${parsedOwner}")`
56+
",",
57+
)}" in current owner ("${parsedOwner}")`,
5658
);
5759
}
5860

@@ -63,8 +65,8 @@ export async function main(
6365

6466
core.info(
6567
`owner and repositories set, creating token for repositories "${repositories.join(
66-
","
67-
)}" owned by "${owner}"`
68+
",",
69+
)}" owned by "${owner}"`,
6870
);
6971
}
7072

@@ -84,31 +86,32 @@ export async function main(
8486
request,
8587
auth,
8688
parsedOwner,
87-
parsedRepositoryNames
89+
parsedRepositoryNames,
90+
permissions,
8891
),
8992
{
9093
onFailedAttempt: (error) => {
9194
core.info(
9295
`Failed to create token for "${parsedRepositoryNames.join(
93-
","
94-
)}" (attempt ${error.attemptNumber}): ${error.message}`
96+
",",
97+
)}" (attempt ${error.attemptNumber}): ${error.message}`,
9598
);
9699
},
97100
retries: 3,
98-
}
101+
},
99102
));
100103
} else {
101104
// Otherwise get the installation for the owner, which can either be an organization or a user account
102105
({ authentication, installationId, appSlug } = await pRetry(
103-
() => getTokenFromOwner(request, auth, parsedOwner),
106+
() => getTokenFromOwner(request, auth, parsedOwner, permissions),
104107
{
105108
onFailedAttempt: (error) => {
106109
core.info(
107-
`Failed to create token for "${parsedOwner}" (attempt ${error.attemptNumber}): ${error.message}`
110+
`Failed to create token for "${parsedOwner}" (attempt ${error.attemptNumber}): ${error.message}`,
108111
);
109112
},
110113
retries: 3,
111-
}
114+
},
112115
));
113116
}
114117

@@ -126,7 +129,7 @@ export async function main(
126129
}
127130
}
128131

129-
async function getTokenFromOwner(request, auth, parsedOwner) {
132+
async function getTokenFromOwner(request, auth, parsedOwner, permissions) {
130133
// https://docs.github.com/rest/apps/apps?apiVersion=2022-11-28#get-a-user-installation-for-the-authenticated-app
131134
// This endpoint works for both users and organizations
132135
const response = await request("GET /users/{username}/installation", {
@@ -140,6 +143,7 @@ async function getTokenFromOwner(request, auth, parsedOwner) {
140143
const authentication = await auth({
141144
type: "installation",
142145
installationId: response.data.id,
146+
permissions,
143147
});
144148

145149
const installationId = response.data.id;
@@ -152,7 +156,8 @@ async function getTokenFromRepository(
152156
request,
153157
auth,
154158
parsedOwner,
155-
parsedRepositoryNames
159+
parsedRepositoryNames,
160+
permissions,
156161
) {
157162
// https://docs.github.com/rest/apps/apps?apiVersion=2022-11-28#get-a-repository-installation-for-the-authenticated-app
158163
const response = await request("GET /repos/{owner}/{repo}/installation", {
@@ -168,6 +173,7 @@ async function getTokenFromRepository(
168173
type: "installation",
169174
installationId: response.data.id,
170175
repositoryNames: parsedRepositoryNames,
176+
permissions,
171177
});
172178

173179
const installationId = response.data.id;

lib/request.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const proxyUrl =
1717
const proxyFetch = (url, options) => {
1818
const urlHost = new URL(url).hostname;
1919
const noProxy = (process.env.no_proxy || process.env.NO_PROXY || "").split(
20-
","
20+
",",
2121
);
2222

2323
if (!noProxy.includes(urlHost)) {

main.js

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createAppAuth } from "@octokit/auth-app";
55

66
import { main } from "./lib/main.js";
77
import request from "./lib/request.js";
8+
import { getPermissionsFromInputs } from "./lib/get-permissions-from-inputs.js";
89

910
if (!process.env.GITHUB_REPOSITORY) {
1011
throw new Error("GITHUB_REPOSITORY missing, must be set to '<owner>/<repo>'");
@@ -25,24 +26,28 @@ if (!privateKey) {
2526
throw new Error("Input required and not supplied: private-key");
2627
}
2728
const owner = core.getInput("owner");
28-
const repositories = core.getInput("repositories")
29+
const repositories = core
30+
.getInput("repositories")
2931
.split(/[\n,]+/)
30-
.map(s => s.trim())
31-
.filter(x => x !== '');
32+
.map((s) => s.trim())
33+
.filter((x) => x !== "");
3234

3335
const skipTokenRevoke = Boolean(
34-
core.getInput("skip-token-revoke") || core.getInput("skip_token_revoke")
36+
core.getInput("skip-token-revoke") || core.getInput("skip_token_revoke"),
3537
);
3638

37-
main(
39+
const permissions = getPermissionsFromInputs(process.env);
40+
41+
export default main(
3842
appId,
3943
privateKey,
4044
owner,
4145
repositories,
46+
permissions,
4247
core,
4348
createAppAuth,
4449
request,
45-
skipTokenRevoke
50+
skipTokenRevoke,
4651
).catch((error) => {
4752
/* c8 ignore next 3 */
4853
console.error(error);

package-lock.json

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

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"@octokit/auth-app": "^7.1.5",
1717
"@octokit/request": "^9.2.2",
1818
"p-retry": "^6.2.1",
19-
"undici": "^7.4.0"
19+
"undici": "^7.5.0"
2020
},
2121
"devDependencies": {
2222
"@octokit/openapi": "^18.0.0",

tests/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,14 @@ or with npm
1717
```
1818
npm test
1919
```
20+
21+
## How the tests work
22+
23+
The output from the tests is captured into a snapshot ([tests/snapshots/index.js.md](snapshots/index.js.md)). It includes all requests sent by our scripts to verify it's working correctly and to prevent regressions.
24+
25+
## How to add a new test
26+
27+
We have tests both for the `main.js` and `post.js` scripts.
28+
29+
- If you do not expect an error, take [main-token-permissions-set.test.js](tests/main-token-permissions-set.test.js) as a starting point.
30+
- If your test has an expected error, take [main-missing-app-id.test.js](tests/main-missing-app-id.test.js) as a starting point.

0 commit comments

Comments
 (0)