Skip to content

add implementation of permissions inputs #217

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Mar 25, 2025
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
15 changes: 15 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Contributing

Initial setup

```console
npm install
```

Run tests locally

```console
npm test
```

Learn more about how the tests work in [test/README.md](test/README.md).
72 changes: 54 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ jobs:

> [!TIP]
> 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`.
>
>
> For example, we can check at `https://api.github.com/users/dependabot[bot]` to see the user ID of Dependabot is 49699333.
>
> Alternatively, you can use the [octokit/request-action](https://github.com/octokit/request-action) to get the ID.
Expand Down Expand Up @@ -195,6 +195,32 @@ jobs:
body: "Hello, World!"
```

### Create a token with specific permissions

> [!NOTE]
> 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.

```yaml
on: [issues]

jobs:
hello-world:
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ vars.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
permission-issues: write
- uses: peter-evans/create-or-update-comment@v3
with:
token: ${{ steps.app-token.outputs.token }}
issue-number: ${{ github.event.issue.number }}
body: "Hello, World!"
```

### Create tokens for multiple user or organization accounts

You can use a matrix strategy to create tokens for multiple user or organization accounts.
Expand Down Expand Up @@ -251,23 +277,23 @@ jobs:
runs-on: self-hosted

steps:
- name: Create GitHub App token
id: create_token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ vars.GHES_APP_ID }}
private-key: ${{ secrets.GHES_APP_PRIVATE_KEY }}
owner: ${{ vars.GHES_INSTALLATION_ORG }}
github-api-url: ${{ vars.GITHUB_API_URL }}

- name: Create issue
uses: octokit/[email protected]
with:
route: POST /repos/${{ github.repository }}/issues
title: "New issue from workflow"
body: "This is a new issue created from a GitHub Action workflow."
env:
GITHUB_TOKEN: ${{ steps.create_token.outputs.token }}
- name: Create GitHub App token
id: create_token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ vars.GHES_APP_ID }}
private-key: ${{ secrets.GHES_APP_PRIVATE_KEY }}
owner: ${{ vars.GHES_INSTALLATION_ORG }}
github-api-url: ${{ vars.GITHUB_API_URL }}

- name: Create issue
uses: octokit/[email protected]
with:
route: POST /repos/${{ github.repository }}/issues
title: "New issue from workflow"
body: "This is a new issue created from a GitHub Action workflow."
env:
GITHUB_TOKEN: ${{ steps.create_token.outputs.token }}
```

## Inputs
Expand Down Expand Up @@ -309,6 +335,12 @@ steps:
> [!NOTE]
> 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.

### `permission-<permission name>`

**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`).

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.

### `skip-token-revoke`

**Optional:** If truthy, the token will not be revoked when the current job is complete.
Expand Down Expand Up @@ -344,6 +376,10 @@ The action creates an installation access token using [the `POST /app/installati
> [!NOTE]
> 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.

## Contributing

[CONTRIBUTING.md](CONTRIBUTING.md)

## License

[MIT](LICENSE)
23 changes: 23 additions & 0 deletions lib/get-permissions-from-inputs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Finds all permissions passed via `permision-*` inputs and turns them into an object.
*
* @see https://docs.github.com/en/actions/sharing-automations/creating-actions/metadata-syntax-for-github-actions#inputs
* @param {NodeJS.ProcessEnv} env
* @returns {undefined | Record<string, string>}
*/
export function getPermissionsFromInputs(env) {
return Object.entries(env).reduce((permissions, [key, value]) => {
if (!key.startsWith("INPUT_PERMISSION_")) return permissions;

const permission = key.slice("INPUT_PERMISSION_".length).toLowerCase();
if (permissions === undefined) {
return { [permission]: value };
}

return {
// @ts-expect-error - needs to be typed correctly
...permissions,
[permission]: value,
};
}, undefined);
}
38 changes: 22 additions & 16 deletions lib/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import pRetry from "p-retry";
* @param {string} privateKey
* @param {string} owner
* @param {string[]} repositories
* @param {undefined | Record<string, string>} permissions
* @param {import("@actions/core")} core
* @param {import("@octokit/auth-app").createAppAuth} createAppAuth
* @param {import("@octokit/request").request} request
Expand All @@ -16,10 +17,11 @@ export async function main(
privateKey,
owner,
repositories,
permissions,
core,
createAppAuth,
request,
skipTokenRevoke
skipTokenRevoke,
) {
let parsedOwner = "";
let parsedRepositoryNames = [];
Expand All @@ -31,7 +33,7 @@ export async function main(
parsedRepositoryNames = [repo];

core.info(
`owner and repositories not set, creating token for the current repository ("${repo}")`
`owner and repositories not set, creating token for the current repository ("${repo}")`,
);
}

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

core.info(
`repositories not set, creating token for all repositories for given owner "${owner}"`
`repositories not set, creating token for all repositories for given owner "${owner}"`,
);
}

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

core.info(
`owner not set, creating owner for given repositories "${repositories.join(
","
)}" in current owner ("${parsedOwner}")`
",",
)}" in current owner ("${parsedOwner}")`,
);
}

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

core.info(
`owner and repositories set, creating token for repositories "${repositories.join(
","
)}" owned by "${owner}"`
",",
)}" owned by "${owner}"`,
);
}

Expand All @@ -84,31 +86,32 @@ export async function main(
request,
auth,
parsedOwner,
parsedRepositoryNames
parsedRepositoryNames,
permissions,
),
{
onFailedAttempt: (error) => {
core.info(
`Failed to create token for "${parsedRepositoryNames.join(
","
)}" (attempt ${error.attemptNumber}): ${error.message}`
",",
)}" (attempt ${error.attemptNumber}): ${error.message}`,
);
},
retries: 3,
}
},
));
} else {
// Otherwise get the installation for the owner, which can either be an organization or a user account
({ authentication, installationId, appSlug } = await pRetry(
() => getTokenFromOwner(request, auth, parsedOwner),
() => getTokenFromOwner(request, auth, parsedOwner, permissions),
{
onFailedAttempt: (error) => {
core.info(
`Failed to create token for "${parsedOwner}" (attempt ${error.attemptNumber}): ${error.message}`
`Failed to create token for "${parsedOwner}" (attempt ${error.attemptNumber}): ${error.message}`,
);
},
retries: 3,
}
},
));
}

Expand All @@ -126,7 +129,7 @@ export async function main(
}
}

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

const installationId = response.data.id;
Expand All @@ -152,7 +156,8 @@ async function getTokenFromRepository(
request,
auth,
parsedOwner,
parsedRepositoryNames
parsedRepositoryNames,
permissions,
) {
// https://docs.github.com/rest/apps/apps?apiVersion=2022-11-28#get-a-repository-installation-for-the-authenticated-app
const response = await request("GET /repos/{owner}/{repo}/installation", {
Expand All @@ -168,6 +173,7 @@ async function getTokenFromRepository(
type: "installation",
installationId: response.data.id,
repositoryNames: parsedRepositoryNames,
permissions,
});

const installationId = response.data.id;
Expand Down
2 changes: 1 addition & 1 deletion lib/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const proxyUrl =
const proxyFetch = (url, options) => {
const urlHost = new URL(url).hostname;
const noProxy = (process.env.no_proxy || process.env.NO_PROXY || "").split(
","
",",
);

if (!noProxy.includes(urlHost)) {
Expand Down
17 changes: 11 additions & 6 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createAppAuth } from "@octokit/auth-app";

import { main } from "./lib/main.js";
import request from "./lib/request.js";
import { getPermissionsFromInputs } from "./lib/get-permissions-from-inputs.js";

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

const skipTokenRevoke = Boolean(
core.getInput("skip-token-revoke") || core.getInput("skip_token_revoke")
core.getInput("skip-token-revoke") || core.getInput("skip_token_revoke"),
);

main(
const permissions = getPermissionsFromInputs(process.env);

export default main(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are exporting the promise returned by main() for testing. We need to await the execution in order to snapshot all requests that were sent by it.

appId,
privateKey,
owner,
repositories,
permissions,
core,
createAppAuth,
request,
skipTokenRevoke
skipTokenRevoke,
).catch((error) => {
/* c8 ignore next 3 */
console.error(error);
Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"@octokit/auth-app": "^7.1.5",
"@octokit/request": "^9.2.2",
"p-retry": "^6.2.1",
"undici": "^7.4.0"
"undici": "^7.5.0"
},
"devDependencies": {
"@octokit/openapi": "^18.0.0",
Expand Down
11 changes: 11 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,14 @@ or with npm
```
npm test
```

## How the tests work

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.

## How to add a new test

We have tests both for the `main.js` and `post.js` scripts.

- 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.
- 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.
Loading