Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
- 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.

### `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:
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
* @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,
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");
}

// 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