Skip to content

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

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
enterprise: my-enterprise-slug
enterprise-slug: my-enterprise-slug

For consistency with how this is referred to elsewhere:

- 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]
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
### `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.
### `enterprise-slug`
**Optional:** The slug of the enterprise to generate a token for enterprise-level app installations.
> [!NOTE]
> The `enterprise-slug` 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.


### `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`).
Expand Down
3 changes: 3 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
enterprise:
enterprise-slug:

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
Expand Down
103 changes: 73 additions & 30 deletions dist/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -42523,47 +42523,67 @@ async function pRetry(input, options) {
}

// lib/main.js
async function main(appId2, privateKey2, owner2, repositories2, permissions2, core3, createAppAuth2, request2, skipTokenRevoke2) {
async function main(appId2, privateKey2, enterprise2, owner2, repositories2, permissions2, core3, createAppAuth2, request2, skipTokenRevoke2) {
if (enterprise2 && (owner2 || repositories2.length > 0)) {
throw new Error("Cannot use 'enterprise' input with 'owner' or 'repositories' inputs");
}
let parsedOwner = "";
let parsedRepositoryNames = [];
if (!owner2 && repositories2.length === 0) {
const [owner3, repo] = String(process.env.GITHUB_REPOSITORY).split("/");
parsedOwner = owner3;
parsedRepositoryNames = [repo];
core3.info(
`Inputs 'owner' and 'repositories' are not set. Creating token for this repository (${owner3}/${repo}).`
);
}
if (owner2 && repositories2.length === 0) {
parsedOwner = owner2;
core3.info(
`Input 'repositories' is not set. Creating token for all repositories owned by ${owner2}.`
);
}
if (!owner2 && repositories2.length > 0) {
parsedOwner = String(process.env.GITHUB_REPOSITORY_OWNER);
parsedRepositoryNames = repositories2;
core3.info(
`No 'owner' input provided. Using default owner '${parsedOwner}' to create token for the following repositories:${repositories2.map((repo) => `
if (!enterprise2) {
if (!owner2 && repositories2.length === 0) {
const [owner3, repo] = String(process.env.GITHUB_REPOSITORY).split("/");
parsedOwner = owner3;
parsedRepositoryNames = [repo];
core3.info(
`Inputs 'owner' and 'repositories' are not set. Creating token for this repository (${owner3}/${repo}).`
);
}
if (owner2 && repositories2.length === 0) {
parsedOwner = owner2;
core3.info(
`Input 'repositories' is not set. Creating token for all repositories owned by ${owner2}.`
);
}
if (!owner2 && repositories2.length > 0) {
parsedOwner = String(process.env.GITHUB_REPOSITORY_OWNER);
parsedRepositoryNames = repositories2;
core3.info(
`No 'owner' input provided. Using default owner '${parsedOwner}' to create token for the following repositories:${repositories2.map((repo) => `
- ${parsedOwner}/${repo}`).join("")}`
);
}
if (owner2 && repositories2.length > 0) {
parsedOwner = owner2;
parsedRepositoryNames = repositories2;
core3.info(
`Inputs 'owner' and 'repositories' are set. Creating token for the following repositories:
${repositories2.map((repo) => `
);
}
if (owner2 && repositories2.length > 0) {
parsedOwner = owner2;
parsedRepositoryNames = repositories2;
core3.info(
`Inputs 'owner' and 'repositories' are set. Creating token for the following repositories:
${repositories2.map((repo) => `
- ${parsedOwner}/${repo}`).join("")}`
);
);
}
} else {
core3.info(`Creating enterprise installation token for enterprise "${enterprise2}".`);
}
const auth5 = createAppAuth2({
appId: appId2,
privateKey: privateKey2,
request: request2
});
let authentication, installationId, appSlug;
if (parsedRepositoryNames.length > 0) {
if (enterprise2) {
({ authentication, installationId, appSlug } = await pRetry(
() => getTokenFromEnterprise(request2, auth5, enterprise2, permissions2),
{
shouldRetry: (error) => error.status >= 500,
onFailedAttempt: (error) => {
core3.info(
`Failed to create token for enterprise "${enterprise2}" (attempt ${error.attemptNumber}): ${error.message}`
);
},
retries: 3
}
));
} else if (parsedRepositoryNames.length > 0) {
({ authentication, installationId, appSlug } = await pRetry(
() => getTokenFromRepository(
request2,
Expand Down Expand Up @@ -42640,6 +42660,27 @@ async function getTokenFromRepository(request2, auth5, parsedOwner, parsedReposi
const appSlug = response.data["app_slug"];
return { authentication, installationId, appSlug };
}
async function getTokenFromEnterprise(request2, auth5, enterprise2, permissions2) {
const response = await request2("GET /app/installations", {
request: {
hook: auth5.hook
}
});
const enterpriseInstallation = response.data.find(
(installation) => installation.target_type === "Enterprise" && installation.account?.slug === enterprise2
);
if (!enterpriseInstallation) {
throw new Error(`No enterprise installation found matching the name ${enterprise2}. Available installations: ${response.data.map((i) => `${i.target_type}:${i.account?.login || "N/A"}`).join(", ")}`);
}
const authentication = await auth5({
type: "installation",
installationId: enterpriseInstallation.id,
permissions: permissions2
});
const installationId = enterpriseInstallation.id;
const appSlug = enterpriseInstallation["app_slug"];
return { authentication, installationId, appSlug };
}

// lib/request.js
var import_core = __toESM(require_core(), 1);
Expand Down Expand Up @@ -42677,13 +42718,15 @@ if (!process.env.GITHUB_REPOSITORY_OWNER) {
}
var appId = import_core2.default.getInput("app-id");
var privateKey = import_core2.default.getInput("private-key");
var enterprise = import_core2.default.getInput("enterprise");
var owner = import_core2.default.getInput("owner");
var repositories = import_core2.default.getInput("repositories").split(/[\n,]+/).map((s) => s.trim()).filter((x) => x !== "");
var skipTokenRevoke = import_core2.default.getBooleanInput("skip-token-revoke");
var permissions = getPermissionsFromInputs(process.env);
var main_default = main(
appId,
privateKey,
enterprise,
owner,
repositories,
permissions,
Expand Down
150 changes: 105 additions & 45 deletions lib/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import pRetry from "p-retry";
/**
* @param {string} appId
* @param {string} privateKey
* @param {string} enterprise
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
* @param {string} enterprise
* @param {string} enterpriseSlug

* @param {string} owner
* @param {string[]} repositories
* @param {undefined | Record<string, string>} permissions
Expand All @@ -15,58 +16,70 @@ import pRetry from "p-retry";
export async function main(
appId,
privateKey,
enterprise,
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
enterprise,
enterpriseSlug,

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,

Copy link
Preview

Copilot AI Jul 11, 2025

Choose a reason for hiding this comment

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

// 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
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
// 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");
// Validate mutual exclusivity of enterprise-slug with owner/repositories
if (enterpriseSlug && (owner || repositories.length > 0)) {
throw new Error("Cannot use 'enterprise-slug' input with 'owner' or 'repositories' inputs");

}

// 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({
Expand All @@ -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(
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

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

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.

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
);

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 };
}
2 changes: 2 additions & 0 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ if (!process.env.GITHUB_REPOSITORY_OWNER) {

const appId = core.getInput("app-id");
const privateKey = core.getInput("private-key");
const enterprise = core.getInput("enterprise");
const owner = core.getInput("owner");
const repositories = core
.getInput("repositories")
Expand All @@ -32,6 +33,7 @@ const permissions = getPermissionsFromInputs(process.env);
export default main(
appId,
privateKey,
enterprise,
owner,
repositories,
permissions,
Expand Down
Loading
Loading