Skip to content

Commit 952a2a7

Browse files
theztefanparkerbxyzCopilotCopilot
authored
feat: add support for enterprise-level GitHub Apps (#263)
This pull request adds support for generating GitHub App installation tokens for enterprise-level installations. ### What changed - Added a new `enterprise` input to `action.yml`. - Wired `enterprise` through `main.js` and `lib/main.js`. - Added validation so `enterprise` cannot be combined with `owner` or `repositories`. - Implemented enterprise installation lookup using the direct GitHub API route `GET /enterprises/{enterprise}/installation`, then used the returned installation ID to mint an installation token through `@octokit/auth-app`. - Updated `README.md` with enterprise installation usage and input documentation. - Updated `dist/main.cjs` for the bundled action. - Shared token creation retry behavior across repository, owner, and enterprise paths so server errors and transient network errors are retried, while client errors fail immediately. ### Tests Added focused test coverage for: - enterprise token creation - enterprise token creation with explicit permissions - enterprise installation not found - mutual exclusivity with `owner` - mutual exclusivity with `repositories` - owner installation client errors are not retried - transient network errors are retried during token creation ### Notes - This keeps the existing repository-scoped token behavior unchanged. - Owner, repository, and enterprise token creation now share the same retry policy: server errors and recognized transient network errors are retried, while client errors fail immediately. This intentionally fixes the previous owner-path behavior that retried client errors. Refs: - https://github.blog/changelog/2025-07-01-enterprise-level-access-for-github-apps-and-installation-automation-apis/ - https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#get-an-enterprise-installation-for-the-authenticated-app --------- Co-authored-by: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 43e5c34 commit 952a2a7

18 files changed

Lines changed: 714 additions & 194 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
.env
22
coverage
33
node_modules/
4+
.DS_Store

README.md

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -195,10 +195,32 @@ jobs:
195195
body: "Hello, World!"
196196
```
197197

198+
### Create a token for an enterprise installation
199+
200+
```yaml
201+
on: [workflow_dispatch]
202+
203+
jobs:
204+
hello-world:
205+
runs-on: ubuntu-latest
206+
steps:
207+
- uses: actions/create-github-app-token@v3
208+
id: app-token
209+
with:
210+
client-id: ${{ vars.APP_CLIENT_ID }}
211+
private-key: ${{ secrets.APP_PRIVATE_KEY }}
212+
enterprise: my-enterprise-slug
213+
- name: Call enterprise management REST API with gh
214+
run: |
215+
gh api /enterprises/my-enterprise-slug/apps/installable_organizations
216+
env:
217+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
218+
```
219+
198220
### Create a token with specific permissions
199221

200222
> [!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.
223+
> Selected permissions must be granted to the specified app installation. Setting a permission that the installation does not have will result in an error.
202224

203225
```yaml
204226
on: [issues]
@@ -356,6 +378,13 @@ steps:
356378
> [!NOTE]
357379
> 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.
358380

381+
### `enterprise`
382+
383+
**Optional:** The slug of the enterprise account to generate a token for an enterprise installation.
384+
385+
> [!NOTE]
386+
> The `enterprise` input is mutually exclusive with `owner` and `repositories`. Use it when the GitHub App is installed on an enterprise account. Enterprise installation tokens can call enterprise APIs, but do not grant organization or repository access.
387+
359388
### `permission-<permission name>`
360389

361390
**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`).
@@ -386,13 +415,14 @@ GitHub App slug.
386415

387416
## How it works
388417

389-
The action creates an installation access token using [the `POST /app/installations/{installation_id}/access_tokens` endpoint](https://docs.github.com/rest/apps/apps?apiVersion=2022-11-28#create-an-installation-access-token-for-an-app). By default,
418+
The action creates an installation access token using [the `POST /app/installations/{installation_id}/access_tokens` endpoint](https://docs.github.com/rest/apps/apps?apiVersion=2022-11-28#create-an-installation-access-token-for-an-app).
419+
420+
The token target depends on the inputs: `enterprise` creates a token for an enterprise installation, `owner` without `repositories` creates a token for all repositories in the owner's installation, `repositories` scopes the token to those repositories, and no target inputs scopes the token to the current repository.
390421

391-
1. The token is scoped to the current repository or `repositories` if set.
392-
2. The token inherits all the installation's permissions.
393-
3. The token is set as output `token` which can be used in subsequent steps.
394-
4. Unless the `skip-token-revoke` input is set to true, the token is revoked in the `post` step of the action, which means it cannot be passed to another job.
395-
5. The token is masked, it cannot be logged accidentally.
422+
1. The token inherits all the installation's permissions.
423+
2. The token is set as output `token` which can be used in subsequent steps.
424+
3. Unless the `skip-token-revoke` input is set to true, the token is revoked in the `post` step of the action, which means it cannot be passed to another job.
425+
4. The token is masked, it cannot be logged accidentally.
396426

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

action.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ inputs:
2121
repositories:
2222
description: "Comma or newline-separated list of repositories to install the GitHub App on (defaults to current repository if owner is unset)"
2323
required: false
24+
enterprise:
25+
description: "The slug of the enterprise account where the GitHub App is installed (cannot be used with 'owner' or 'repositories')"
26+
required: false
2427
skip-token-revoke:
2528
description: "If true, the token will not be revoked when the current job is complete"
2629
required: false

dist/main.cjs

Lines changed: 122 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -22985,7 +22985,7 @@ function isNetworkError(error2) {
2298522985
return false;
2298622986
}
2298722987
const { message, stack } = error2;
22988-
if (message === "Load failed") {
22988+
if (message === "Load failed" || message.startsWith("Load failed (") && message.endsWith(")")) {
2298922989
return stack === void 0 || "__sentry_captured__" in error2;
2299022990
}
2299122991
if (message.startsWith("error sending request for url")) {
@@ -23196,104 +23196,133 @@ async function pRetry(input, options = {}) {
2319623196
}
2319723197

2319823198
// lib/main.js
23199-
async function main(clientId, privateKey, owner, repositories, permissions, core, createAppAuth2, request2, skipTokenRevoke) {
23200-
let parsedOwner = "";
23201-
let parsedRepositoryNames = [];
23199+
async function main(clientId, privateKey, enterprise, owner, repositories, permissions, core, createAppAuth2, request2, skipTokenRevoke) {
23200+
if (enterprise && (owner || repositories.length > 0)) {
23201+
throw new Error("Cannot use 'enterprise' input with 'owner' or 'repositories' inputs");
23202+
}
23203+
const target = resolveInstallationTarget(enterprise, owner, repositories, core);
23204+
const auth5 = createAppAuth2({
23205+
appId: clientId,
23206+
privateKey,
23207+
request: request2
23208+
});
23209+
const { authentication, installationId, appSlug } = await pRetry(
23210+
() => getTokenFromTarget(request2, auth5, target, permissions),
23211+
createTokenRetryOptions(core, getTokenRetryDescription(target))
23212+
);
23213+
core.setSecret(authentication.token);
23214+
core.setOutput("token", authentication.token);
23215+
core.setOutput("installation-id", installationId);
23216+
core.setOutput("app-slug", appSlug);
23217+
if (!skipTokenRevoke) {
23218+
core.saveState("token", authentication.token);
23219+
core.saveState("expiresAt", authentication.expiresAt);
23220+
}
23221+
}
23222+
function resolveInstallationTarget(enterprise, owner, repositories, core) {
23223+
if (enterprise) {
23224+
core.info(`Creating enterprise installation token for enterprise "${enterprise}".`);
23225+
return { type: "enterprise", enterprise };
23226+
}
2320223227
if (!owner && repositories.length === 0) {
23203-
const [owner2, repo] = String(process.env.GITHUB_REPOSITORY).split("/");
23204-
parsedOwner = owner2;
23205-
parsedRepositoryNames = [repo];
23228+
const [defaultOwner, repo] = String(process.env.GITHUB_REPOSITORY).split("/");
2320623229
core.info(
23207-
`Inputs 'owner' and 'repositories' are not set. Creating token for this repository (${owner2}/${repo}).`
23230+
`Inputs 'owner' and 'repositories' are not set. Creating token for this repository (${defaultOwner}/${repo}).`
2320823231
);
23232+
return {
23233+
type: "repository",
23234+
owner: defaultOwner,
23235+
repositories: [repo]
23236+
};
2320923237
}
2321023238
if (owner && repositories.length === 0) {
23211-
parsedOwner = owner;
2321223239
core.info(
2321323240
`Input 'repositories' is not set. Creating token for all repositories owned by ${owner}.`
2321423241
);
23242+
return { type: "owner", owner };
2321523243
}
23216-
if (!owner && repositories.length > 0) {
23217-
parsedOwner = String(process.env.GITHUB_REPOSITORY_OWNER);
23218-
parsedRepositoryNames = repositories;
23244+
const parsedOwner = owner || String(process.env.GITHUB_REPOSITORY_OWNER);
23245+
if (!owner) {
2321923246
core.info(
2322023247
`No 'owner' input provided. Using default owner '${parsedOwner}' to create token for the following repositories:${repositories.map((repo) => `
2322123248
- ${parsedOwner}/${repo}`).join("")}`
2322223249
);
23223-
}
23224-
if (owner && repositories.length > 0) {
23225-
parsedOwner = owner;
23226-
parsedRepositoryNames = repositories;
23250+
} else {
2322723251
core.info(
23228-
`Inputs 'owner' and 'repositories' are set. Creating token for the following repositories:
23229-
${repositories.map((repo) => `
23252+
`Inputs 'owner' and 'repositories' are set. Creating token for the following repositories:${repositories.map((repo) => `
2323023253
- ${parsedOwner}/${repo}`).join("")}`
2323123254
);
2323223255
}
23233-
const auth5 = createAppAuth2({
23234-
appId: clientId,
23235-
privateKey,
23236-
request: request2
23237-
});
23238-
let authentication, installationId, appSlug;
23239-
if (parsedRepositoryNames.length > 0) {
23240-
({ authentication, installationId, appSlug } = await pRetry(
23241-
() => getTokenFromRepository(
23256+
return {
23257+
type: "repository",
23258+
owner: parsedOwner,
23259+
repositories
23260+
};
23261+
}
23262+
function getTokenRetryDescription(target) {
23263+
switch (target.type) {
23264+
case "enterprise":
23265+
return `enterprise "${target.enterprise}"`;
23266+
case "repository":
23267+
return `"${target.repositories.map((repository) => `${target.owner}/${repository}`).join(",")}"`;
23268+
case "owner":
23269+
return `"${target.owner}"`;
23270+
/* c8 ignore next 2 */
23271+
default:
23272+
throw new Error(`Unsupported installation target type: ${target.type}`);
23273+
}
23274+
}
23275+
function getTokenFromTarget(request2, auth5, target, permissions) {
23276+
switch (target.type) {
23277+
case "enterprise":
23278+
return getTokenFromEnterprise(request2, auth5, target.enterprise, permissions);
23279+
case "repository":
23280+
return getTokenFromRepository(
2324223281
request2,
2324323282
auth5,
23244-
parsedOwner,
23245-
parsedRepositoryNames,
23283+
target.owner,
23284+
target.repositories,
2324623285
permissions
23247-
),
23248-
{
23249-
shouldRetry: ({ error: error2 }) => error2.status >= 500,
23250-
onFailedAttempt: (context) => {
23251-
core.info(
23252-
`Failed to create token for "${parsedRepositoryNames.join(
23253-
","
23254-
)}" (attempt ${context.attemptNumber}): ${context.error.message}`
23255-
);
23256-
},
23257-
retries: 3
23258-
}
23259-
));
23260-
} else {
23261-
({ authentication, installationId, appSlug } = await pRetry(
23262-
() => getTokenFromOwner(request2, auth5, parsedOwner, permissions),
23263-
{
23264-
onFailedAttempt: (context) => {
23265-
core.info(
23266-
`Failed to create token for "${parsedOwner}" (attempt ${context.attemptNumber}): ${context.error.message}`
23267-
);
23268-
},
23269-
retries: 3
23270-
}
23271-
));
23272-
}
23273-
core.setSecret(authentication.token);
23274-
core.setOutput("token", authentication.token);
23275-
core.setOutput("installation-id", installationId);
23276-
core.setOutput("app-slug", appSlug);
23277-
if (!skipTokenRevoke) {
23278-
core.saveState("token", authentication.token);
23279-
core.saveState("expiresAt", authentication.expiresAt);
23286+
);
23287+
case "owner":
23288+
return getTokenFromOwner(request2, auth5, target.owner, permissions);
23289+
/* c8 ignore next 2 */
23290+
default:
23291+
throw new Error(`Unsupported installation target type: ${target.type}`);
2328023292
}
2328123293
}
23294+
function createTokenRetryOptions(core, targetDescription) {
23295+
return {
23296+
shouldRetry: ({ error: error2 }) => error2.status >= 500 || isNetworkError(error2),
23297+
onFailedAttempt: (context) => {
23298+
core.info(
23299+
`Failed to create token for ${targetDescription} (attempt ${context.attemptNumber}): ${context.error.message}`
23300+
);
23301+
},
23302+
retries: 3
23303+
};
23304+
}
23305+
async function createInstallationAuthResult(auth5, installation, permissions, options = {}) {
23306+
const authentication = await auth5({
23307+
type: "installation",
23308+
installationId: installation.id,
23309+
permissions,
23310+
...options
23311+
});
23312+
return {
23313+
authentication,
23314+
installationId: installation.id,
23315+
appSlug: installation["app_slug"]
23316+
};
23317+
}
2328223318
async function getTokenFromOwner(request2, auth5, parsedOwner, permissions) {
2328323319
const response = await request2("GET /users/{username}/installation", {
2328423320
username: parsedOwner,
2328523321
request: {
2328623322
hook: auth5.hook
2328723323
}
2328823324
});
23289-
const authentication = await auth5({
23290-
type: "installation",
23291-
installationId: response.data.id,
23292-
permissions
23293-
});
23294-
const installationId = response.data.id;
23295-
const appSlug = response.data["app_slug"];
23296-
return { authentication, installationId, appSlug };
23325+
return createInstallationAuthResult(auth5, response.data, permissions);
2329723326
}
2329823327
async function getTokenFromRepository(request2, auth5, parsedOwner, parsedRepositoryNames, permissions) {
2329923328
const response = await request2("GET /repos/{owner}/{repo}/installation", {
@@ -23303,15 +23332,28 @@ async function getTokenFromRepository(request2, auth5, parsedOwner, parsedReposi
2330323332
hook: auth5.hook
2330423333
}
2330523334
});
23306-
const authentication = await auth5({
23307-
type: "installation",
23308-
installationId: response.data.id,
23309-
repositoryNames: parsedRepositoryNames,
23310-
permissions
23335+
return createInstallationAuthResult(auth5, response.data, permissions, {
23336+
repositoryNames: parsedRepositoryNames
2331123337
});
23312-
const installationId = response.data.id;
23313-
const appSlug = response.data["app_slug"];
23314-
return { authentication, installationId, appSlug };
23338+
}
23339+
async function getTokenFromEnterprise(request2, auth5, enterprise, permissions) {
23340+
let response;
23341+
try {
23342+
response = await request2("GET /enterprises/{enterprise}/installation", {
23343+
enterprise,
23344+
request: {
23345+
hook: auth5.hook
23346+
}
23347+
});
23348+
} catch (error2) {
23349+
if (error2.status === 404) {
23350+
throw new Error(
23351+
`No enterprise installation found matching the enterprise slug "${enterprise}".`
23352+
);
23353+
}
23354+
throw error2;
23355+
}
23356+
return createInstallationAuthResult(auth5, response.data, permissions);
2331523357
}
2331623358

2331723359
// lib/request.js
@@ -23355,13 +23397,15 @@ async function run() {
2335523397
throw new Error("The 'client-id' (or deprecated 'app-id') input must be set to a non-empty string. If using a secret or variable, ensure it is available in this workflow context.");
2335623398
}
2335723399
const privateKey = getInput("private-key");
23400+
const enterprise = getInput("enterprise");
2335823401
const owner = getInput("owner");
2335923402
const repositories = getInput("repositories").split(/[\n,]+/).map((s) => s.trim()).filter((x) => x !== "");
2336023403
const skipTokenRevoke = getBooleanInput("skip-token-revoke");
2336123404
const permissions = getPermissionsFromInputs(process.env);
2336223405
return main(
2336323406
clientId,
2336423407
privateKey,
23408+
enterprise,
2336523409
owner,
2336623410
repositories,
2336723411
permissions,

0 commit comments

Comments
 (0)