Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 50 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/request-action@v2.x
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/request-action@v2.x
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 permission level to grant to the token. By default, the token inherits all the installation's permissions. We recommend to explicitly list the permissions that are required for a use case. This follow's 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 replace e.g. `pull-requests` with `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
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
32 changes: 27 additions & 5 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,46 @@ 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"),
);

// @see https://docs.github.com/en/actions/sharing-automations/creating-actions/metadata-syntax-for-github-actions#inputs
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);
}

const permissions = getPermissionsFromInputs(process.env);

main(
appId,
privateKey,
owner,
repositories,
permissions,
core,
createAppAuth,
request,
skipTokenRevoke
skipTokenRevoke,
).catch((error) => {
/* c8 ignore next 3 */
console.error(error);
Expand Down
36 changes: 20 additions & 16 deletions tests/main-repo-skew.test.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import { test } from "./main.js";
import { test, getLogOnceOnPath } from "./main.js";

import { install } from "@sinonjs/fake-timers";

// Verify `main` retry when the clock has drifted.
await test((mockPool) => {
process.env.INPUT_OWNER = 'actions'
process.env.INPUT_REPOSITORIES = 'failed-repo';
const owner = process.env.INPUT_OWNER
const repo = process.env.INPUT_REPOSITORIES
process.env.INPUT_OWNER = "actions";
process.env.INPUT_REPOSITORIES = "failed-repo";
const owner = process.env.INPUT_OWNER;
const repo = process.env.INPUT_REPOSITORIES;
const mockInstallationId = "123456";
const mockAppSlug = "github-actions";

install({ now: 0, toFake: ["Date"] });

mockPool
.intercept({
path: `/repos/${owner}/${repo}/installation`,
path: getLogOnceOnPath(`/repos/${owner}/${repo}/installation`),
method: "GET",
headers: {
accept: "application/vnd.github.v3+json",
Expand All @@ -25,34 +25,38 @@ await test((mockPool) => {
})
.reply(({ headers }) => {
const [_, jwt] = (headers.authorization || "").split(" ");
const payload = JSON.parse(Buffer.from(jwt.split(".")[1], "base64").toString());
const payload = JSON.parse(
Buffer.from(jwt.split(".")[1], "base64").toString(),
);

if (payload.iat < 0) {
return {
statusCode: 401,
data: {
message: "'Issued at' claim ('iat') must be an Integer representing the time that the assertion was issued."
message:
"'Issued at' claim ('iat') must be an Integer representing the time that the assertion was issued.",
},
responseOptions: {
headers: {
"content-type": "application/json",
"date": new Date(Date.now() + 30000).toUTCString()
}
}
date: new Date(Date.now() + 30000).toUTCString(),
},
},
};
}

return {
statusCode: 200,
data: {
id: mockInstallationId,
"app_slug": mockAppSlug
app_slug: mockAppSlug,
},
responseOptions: {
headers: {
"content-type": "application/json"
}
}
"content-type": "application/json",
},
},
};
}).times(2);
})
.times(2);
});
Loading