-
Notifications
You must be signed in to change notification settings - Fork 109
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 4 commits
cbc2930
55b8c24
3c69395
46f9f78
7434028
81e8c22
a84c82d
7b86061
3b3f07c
22e6bc6
6cf7b5f
14350b6
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 | ||
---|---|---|---|---|
|
@@ -4,6 +4,7 @@ import pRetry from "p-retry"; | |||
/** | ||||
* @param {string} appId | ||||
* @param {string} privateKey | ||||
* @param {string} enterprise | ||||
theztefan marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||
* @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, | ||||
theztefan marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||
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"); | ||||
theztefan marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||
} | ||||
|
||||
// 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.
Outdated
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 }; | ||||
} |
Uh oh!
There was an error while loading. Please reload this page.