-
Notifications
You must be signed in to change notification settings - Fork 93
Add support for enterprise level GitHub Apps #263
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -195,6 +195,28 @@ jobs: | |||||||||||||||||||||||||
body: "Hello, World!" | ||||||||||||||||||||||||||
``` | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
### Create a token for an enterprise installation | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
```yaml | ||||||||||||||||||||||||||
on: [workflow_dispatch] | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
jobs: | ||||||||||||||||||||||||||
hello-world: | ||||||||||||||||||||||||||
runs-on: ubuntu-latest | ||||||||||||||||||||||||||
steps: | ||||||||||||||||||||||||||
- uses: actions/create-github-app-token@v2 | ||||||||||||||||||||||||||
id: app-token | ||||||||||||||||||||||||||
with: | ||||||||||||||||||||||||||
app-id: ${{ vars.APP_ID }} | ||||||||||||||||||||||||||
private-key: ${{ secrets.PRIVATE_KEY }} | ||||||||||||||||||||||||||
enterprise: my-enterprise-slug | ||||||||||||||||||||||||||
- name: Call enterprise management REST API with gh | ||||||||||||||||||||||||||
run: | | ||||||||||||||||||||||||||
gh api /enterprises/my-enterprise-slug/apps/installable_organizations | ||||||||||||||||||||||||||
env: | ||||||||||||||||||||||||||
GH_TOKEN: ${{ steps.app-token.outputs.token }} | ||||||||||||||||||||||||||
``` | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
### Create a token with specific permissions | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
> [!NOTE] | ||||||||||||||||||||||||||
|
@@ -335,6 +357,13 @@ 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. | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
### `enterprise` | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
**Optional:** The slug of the enterprise to generate a token for enterprise-level app installations. | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
> [!NOTE] | ||||||||||||||||||||||||||
> The `enterprise` input is mutually exclusive with `owner` and `repositories`. GitHub Apps can be installed on enterprise accounts with permissions that let them call enterprise management APIs. Enterprise installations do not grant access to organization or repository resources. | ||||||||||||||||||||||||||
Comment on lines
+360
to
+365
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
### `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`). | ||||||||||||||||||||||||||
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -17,6 +17,9 @@ inputs: | |||||
repositories: | ||||||
description: "Comma or newline-separated list of repositories to install the GitHub App on (defaults to current repository if owner is unset)" | ||||||
required: false | ||||||
enterprise: | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
description: "Enterprise slug for enterprise-level app installations (cannot be used with 'owner' or 'repositories')" | ||||||
required: false | ||||||
skip-token-revoke: | ||||||
description: "If true, the token will not be revoked when the current job is complete" | ||||||
required: false | ||||||
|
Original file line number | Diff line number | Diff line change | ||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -4,6 +4,7 @@ import pRetry from "p-retry"; | |||||||||||||
/** | ||||||||||||||
* @param {string} appId | ||||||||||||||
* @param {string} privateKey | ||||||||||||||
* @param {string} enterprise | ||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||
* @param {string} owner | ||||||||||||||
* @param {string[]} repositories | ||||||||||||||
* @param {undefined | Record<string, string>} permissions | ||||||||||||||
|
@@ -15,58 +16,70 @@ import pRetry from "p-retry"; | |||||||||||||
export async function main( | ||||||||||||||
appId, | ||||||||||||||
privateKey, | ||||||||||||||
enterprise, | ||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||
owner, | ||||||||||||||
repositories, | ||||||||||||||
permissions, | ||||||||||||||
core, | ||||||||||||||
createAppAuth, | ||||||||||||||
request, | ||||||||||||||
skipTokenRevoke | ||||||||||||||
) { | ||||||||||||||
let parsedOwner = ""; | ||||||||||||||
let parsedRepositoryNames = []; | ||||||||||||||
|
||||||||||||||
// If neither owner nor repositories are set, default to current repository | ||||||||||||||
if (!owner && repositories.length === 0) { | ||||||||||||||
const [owner, repo] = String(process.env.GITHUB_REPOSITORY).split("/"); | ||||||||||||||
parsedOwner = owner; | ||||||||||||||
parsedRepositoryNames = [repo]; | ||||||||||||||
|
||||||||||||||
core.info( | ||||||||||||||
`Inputs 'owner' and 'repositories' are not set. Creating token for this repository (${owner}/${repo}).` | ||||||||||||||
); | ||||||||||||||
} | ||||||||||||||
skipTokenRevoke, | ||||||||||||||
|
||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nitpick] The trailing comma followed by an empty line and then the closing parenthesis creates inconsistent formatting. Either move the closing parenthesis to the same line or remove the empty line.
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||||||||||
// If only an owner is set, default to all repositories from that owner | ||||||||||||||
if (owner && repositories.length === 0) { | ||||||||||||||
parsedOwner = owner; | ||||||||||||||
|
||||||||||||||
core.info( | ||||||||||||||
`Input 'repositories' is not set. Creating token for all repositories owned by ${owner}.` | ||||||||||||||
); | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
// If repositories are set, but no owner, default to `GITHUB_REPOSITORY_OWNER` | ||||||||||||||
if (!owner && repositories.length > 0) { | ||||||||||||||
parsedOwner = String(process.env.GITHUB_REPOSITORY_OWNER); | ||||||||||||||
parsedRepositoryNames = repositories; | ||||||||||||||
|
||||||||||||||
core.info( | ||||||||||||||
`No 'owner' input provided. Using default owner '${parsedOwner}' to create token for the following repositories:${repositories | ||||||||||||||
.map((repo) => `\n- ${parsedOwner}/${repo}`) | ||||||||||||||
.join("")}` | ||||||||||||||
); | ||||||||||||||
) { | ||||||||||||||
// Validate mutual exclusivity of enterprise with owner/repositories | ||||||||||||||
if (enterprise && (owner || repositories.length > 0)) { | ||||||||||||||
throw new Error("Cannot use 'enterprise' input with 'owner' or 'repositories' inputs"); | ||||||||||||||
Comment on lines
+29
to
+31
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||
} | ||||||||||||||
|
||||||||||||||
// If both owner and repositories are set, use those values | ||||||||||||||
if (owner && repositories.length > 0) { | ||||||||||||||
parsedOwner = owner; | ||||||||||||||
parsedRepositoryNames = repositories; | ||||||||||||||
let parsedOwner = ""; | ||||||||||||||
let parsedRepositoryNames = []; | ||||||||||||||
|
||||||||||||||
core.info( | ||||||||||||||
`Inputs 'owner' and 'repositories' are set. Creating token for the following repositories: | ||||||||||||||
${repositories.map((repo) => `\n- ${parsedOwner}/${repo}`).join("")}` | ||||||||||||||
); | ||||||||||||||
// Skip owner/repository parsing if enterprise is set | ||||||||||||||
if (!enterprise) { | ||||||||||||||
// If neither owner nor repositories are set, default to current repository | ||||||||||||||
if (!owner && repositories.length === 0) { | ||||||||||||||
const [owner, repo] = String(process.env.GITHUB_REPOSITORY).split("/"); | ||||||||||||||
parsedOwner = owner; | ||||||||||||||
parsedRepositoryNames = [repo]; | ||||||||||||||
|
||||||||||||||
core.info( | ||||||||||||||
`Inputs 'owner' and 'repositories' are not set. Creating token for this repository (${owner}/${repo}).` | ||||||||||||||
); | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
// If only an owner is set, default to all repositories from that owner | ||||||||||||||
if (owner && repositories.length === 0) { | ||||||||||||||
parsedOwner = owner; | ||||||||||||||
|
||||||||||||||
core.info( | ||||||||||||||
`Input 'repositories' is not set. Creating token for all repositories owned by ${owner}.` | ||||||||||||||
); | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
// If repositories are set, but no owner, default to `GITHUB_REPOSITORY_OWNER` | ||||||||||||||
if (!owner && repositories.length > 0) { | ||||||||||||||
parsedOwner = String(process.env.GITHUB_REPOSITORY_OWNER); | ||||||||||||||
parsedRepositoryNames = repositories; | ||||||||||||||
|
||||||||||||||
core.info( | ||||||||||||||
`No 'owner' input provided. Using default owner '${parsedOwner}' to create token for the following repositories:${repositories | ||||||||||||||
.map((repo) => `\n- ${parsedOwner}/${repo}`) | ||||||||||||||
.join("")}` | ||||||||||||||
); | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
// If both owner and repositories are set, use those values | ||||||||||||||
if (owner && repositories.length > 0) { | ||||||||||||||
parsedOwner = owner; | ||||||||||||||
parsedRepositoryNames = repositories; | ||||||||||||||
|
||||||||||||||
core.info( | ||||||||||||||
`Inputs 'owner' and 'repositories' are set. Creating token for the following repositories: | ||||||||||||||
${repositories.map((repo) => `\n- ${parsedOwner}/${repo}`).join("")}` | ||||||||||||||
); | ||||||||||||||
} | ||||||||||||||
} else { | ||||||||||||||
core.info(`Creating enterprise installation token for enterprise "${enterprise}".`); | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
const auth = createAppAuth({ | ||||||||||||||
|
@@ -76,9 +89,22 @@ export async function main( | |||||||||||||
}); | ||||||||||||||
|
||||||||||||||
let authentication, installationId, appSlug; | ||||||||||||||
// If at least one repository is set, get installation ID from that repository | ||||||||||||||
|
||||||||||||||
if (parsedRepositoryNames.length > 0) { | ||||||||||||||
|
||||||||||||||
// If enterprise is set, get installation ID from the enterprise | ||||||||||||||
if (enterprise) { | ||||||||||||||
({ authentication, installationId, appSlug } = await pRetry( | ||||||||||||||
() => getTokenFromEnterprise(request, auth, enterprise, permissions), | ||||||||||||||
{ | ||||||||||||||
shouldRetry: (error) => error.status >= 500, | ||||||||||||||
onFailedAttempt: (error) => { | ||||||||||||||
core.info( | ||||||||||||||
`Failed to create token for enterprise "${enterprise}" (attempt ${error.attemptNumber}): ${error.message}` | ||||||||||||||
); | ||||||||||||||
}, | ||||||||||||||
retries: 3, | ||||||||||||||
} | ||||||||||||||
)); | ||||||||||||||
} else if (parsedRepositoryNames.length > 0) { | ||||||||||||||
({ authentication, installationId, appSlug } = await pRetry( | ||||||||||||||
() => | ||||||||||||||
getTokenFromRepository( | ||||||||||||||
|
@@ -181,3 +207,37 @@ async function getTokenFromRepository( | |||||||||||||
|
||||||||||||||
return { authentication, installationId, appSlug }; | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
async function getTokenFromEnterprise(request, auth, enterprise, permissions) { | ||||||||||||||
// Get all installations and find the enterprise one | ||||||||||||||
// https://docs.github.com/rest/apps/apps#list-installations-for-the-authenticated-app | ||||||||||||||
// Note: Currently we do not have a way to get the installation for an enterprise directly, | ||||||||||||||
// so as a workaround we need to list all installations and filter for the enterprise one. | ||||||||||||||
const response = await request("GET /app/installations", { | ||||||||||||||
request: { | ||||||||||||||
hook: auth.hook, | ||||||||||||||
}, | ||||||||||||||
}); | ||||||||||||||
Comment on lines
+212
to
+220
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Unfortunately, we will probably need to wait until there is a way to get the installation for an enterprise directly. Otherwise, we'll need to add pagination support for this to be reliable. This could be done via Octokit, but we've tried to keep dependencies to a minimum and would prefer to keep it that way. |
||||||||||||||
|
||||||||||||||
// Find the enterprise installation | ||||||||||||||
const enterpriseInstallation = response.data.find( | ||||||||||||||
installation => installation.target_type === "Enterprise" && | ||||||||||||||
installation.account?.slug === enterprise | ||||||||||||||
theztefan marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||
); | ||||||||||||||
|
||||||||||||||
if (!enterpriseInstallation) { | ||||||||||||||
throw new Error(`No enterprise installation found matching the name ${enterprise}. Available installations: ${response.data.map(i => `${i.target_type}:${i.account?.login || 'N/A'}`).join(', ')}`); | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
// Get token for the enterprise installation | ||||||||||||||
const authentication = await auth({ | ||||||||||||||
type: "installation", | ||||||||||||||
installationId: enterpriseInstallation.id, | ||||||||||||||
permissions, | ||||||||||||||
}); | ||||||||||||||
|
||||||||||||||
const installationId = enterpriseInstallation.id; | ||||||||||||||
const appSlug = enterpriseInstallation["app_slug"]; | ||||||||||||||
|
||||||||||||||
return { authentication, installationId, appSlug }; | ||||||||||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For consistency with how this is referred to elsewhere: