Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
cbc2930
enterprise input; logic to generate ent token
theztefan Jul 8, 2025
55b8c24
tests; update README
theztefan Jul 8, 2025
3c69395
update package version
theztefan Jul 8, 2025
46f9f78
improve installation match; refactor test per copilot review
theztefan Jul 8, 2025
7434028
Update README.md
theztefan Aug 28, 2025
81e8c22
Update README.md
theztefan Aug 28, 2025
a84c82d
Update action.yml
theztefan Aug 28, 2025
7b86061
Update lib/main.js
theztefan Aug 28, 2025
3b3f07c
Update lib/main.js
theztefan Aug 28, 2025
22e6bc6
Update lib/main.js
theztefan Aug 28, 2025
6cf7b5f
update tests with enterprise-slug
theztefan Aug 28, 2025
14350b6
bump version
theztefan Aug 28, 2025
b242740
Merge origin/main into enterprise-app-enterprise-slug
parkerbxyz Mar 13, 2026
2156e19
Remove dist changes
parkerbxyz Mar 13, 2026
77d42ce
Merge latest origin/main
parkerbxyz Mar 14, 2026
4f9eedd
Use direct enterprise installation route
parkerbxyz Mar 14, 2026
c7725c0
Apply suggestions from code review
parkerbxyz Mar 14, 2026
7b114ed
Add newline to .gitignore
parkerbxyz Mar 14, 2026
f90c44a
Remove redundant enterprise tests
parkerbxyz Mar 14, 2026
9175c03
Upgrade GitHub Action to v3
parkerbxyz Mar 14, 2026
50b5a08
Stabilize stderr snapshots
parkerbxyz Mar 14, 2026
17e8e94
Build dist files for testing
parkerbxyz Mar 20, 2026
f942b77
Rename enterprise input
parkerbxyz Mar 21, 2026
c28e731
Clarify enterprise input wording
parkerbxyz Mar 21, 2026
7b2a5fb
Restore failure semantics
parkerbxyz Mar 21, 2026
a2a14fd
Simplify enterprise target flow
parkerbxyz Mar 21, 2026
8b90615
Extract installation auth helper
parkerbxyz Mar 21, 2026
de40320
Test enterprise retry path
parkerbxyz Mar 21, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.env
coverage
node_modules/
.DS_Store
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@v3
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 @@ -353,6 +375,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 version of the enterprise name 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: "The slug version of the enterprise name for enterprise-level app installations (cannot be used with 'owner' or 'repositories')"
required: false
Comment on lines +20 to +22
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

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

PR description mentions adding a new enterprise-slug input, but the actual input added/used is named enterprise (and main.js/tests/README align to that). Please update the PR description (or rename the input) so users aren’t confused about the correct input name.

Copilot uses AI. Check for mistakes.
skip-token-revoke:
description: "If true, the token will not be revoked when the current job is complete"
required: false
Expand Down
156 changes: 101 additions & 55 deletions dist/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -23153,60 +23153,44 @@ async function pRetry(input, options = {}) {
}

// lib/main.js
async function main(appId, privateKey, owner, repositories, permissions, core, createAppAuth2, request2, skipTokenRevoke) {
let parsedOwner = "";
let parsedRepositoryNames = [];
if (!owner && repositories.length === 0) {
const [owner2, repo] = String(process.env.GITHUB_REPOSITORY).split("/");
parsedOwner = owner2;
parsedRepositoryNames = [repo];
core.info(
`Inputs 'owner' and 'repositories' are not set. Creating token for this repository (${owner2}/${repo}).`
);
}
if (owner && repositories.length === 0) {
parsedOwner = owner;
core.info(
`Input 'repositories' is not set. Creating token for all repositories owned by ${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) => `
- ${parsedOwner}/${repo}`).join("")}`
);
}
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) => `
- ${parsedOwner}/${repo}`).join("")}`
);
async function main(appId, privateKey, enterprise, owner, repositories, permissions, core, createAppAuth2, request2, skipTokenRevoke) {
if (enterprise && (owner || repositories.length > 0)) {
throw new Error("Cannot use 'enterprise' input with 'owner' or 'repositories' inputs");
}
const target = resolveInstallationTarget(enterprise, owner, repositories, core);
const auth5 = createAppAuth2({
appId,
privateKey,
request: request2
});
let authentication, installationId, appSlug;
if (parsedRepositoryNames.length > 0) {
if (target.type === "enterprise") {
({ authentication, installationId, appSlug } = await pRetry(
() => getTokenFromEnterprise(request2, auth5, target.enterprise, permissions),
{
shouldRetry: ({ error: error2 }) => error2.status >= 500,
onFailedAttempt: (context) => {
core.info(
`Failed to create token for enterprise "${target.enterprise}" (attempt ${context.attemptNumber}): ${context.error.message}`
);
},
retries: 3
}
));
} else if (target.type === "repository") {
({ authentication, installationId, appSlug } = await pRetry(
() => getTokenFromRepository(
request2,
auth5,
parsedOwner,
parsedRepositoryNames,
target.owner,
target.repositories,
permissions
),
{
shouldRetry: ({ error: error2 }) => error2.status >= 500,
onFailedAttempt: (context) => {
core.info(
`Failed to create token for "${parsedRepositoryNames.join(
`Failed to create token for "${target.repositories.join(
","
)}" (attempt ${context.attemptNumber}): ${context.error.message}`
);
Expand All @@ -23216,11 +23200,11 @@ async function main(appId, privateKey, owner, repositories, permissions, core, c
));
} else {
({ authentication, installationId, appSlug } = await pRetry(
() => getTokenFromOwner(request2, auth5, parsedOwner, permissions),
() => getTokenFromOwner(request2, auth5, target.owner, permissions),
{
onFailedAttempt: (context) => {
core.info(
`Failed to create token for "${parsedOwner}" (attempt ${context.attemptNumber}): ${context.error.message}`
`Failed to create token for "${target.owner}" (attempt ${context.attemptNumber}): ${context.error.message}`
);
},
retries: 3
Expand All @@ -23236,21 +23220,68 @@ async function main(appId, privateKey, owner, repositories, permissions, core, c
core.saveState("expiresAt", authentication.expiresAt);
}
}
function resolveInstallationTarget(enterprise, owner, repositories, core) {
if (enterprise) {
core.info(`Creating enterprise installation token for enterprise "${enterprise}".`);
return { type: "enterprise", enterprise };
}
if (!owner && repositories.length === 0) {
const [defaultOwner, repo] = String(process.env.GITHUB_REPOSITORY).split("/");
core.info(
`Inputs 'owner' and 'repositories' are not set. Creating token for this repository (${defaultOwner}/${repo}).`
);
return {
type: "repository",
owner: defaultOwner,
repositories: [repo]
};
}
if (owner && repositories.length === 0) {
core.info(
`Input 'repositories' is not set. Creating token for all repositories owned by ${owner}.`
);
return { type: "owner", owner };
}
const parsedOwner = owner || String(process.env.GITHUB_REPOSITORY_OWNER);
if (!owner) {
core.info(
`No 'owner' input provided. Using default owner '${parsedOwner}' to create token for the following repositories:${repositories.map((repo) => `
- ${parsedOwner}/${repo}`).join("")}`
);
} else {
core.info(
`Inputs 'owner' and 'repositories' are set. Creating token for the following repositories:
${repositories.map((repo) => `
- ${parsedOwner}/${repo}`).join("")}`
);
}
return {
type: "repository",
owner: parsedOwner,
repositories
};
}
async function createInstallationAuthResult(auth5, installation, permissions, options = {}) {
const authentication = await auth5({
type: "installation",
installationId: installation.id,
permissions,
...options
});
return {
authentication,
installationId: installation.id,
appSlug: installation["app_slug"]
};
}
async function getTokenFromOwner(request2, auth5, parsedOwner, permissions) {
const response = await request2("GET /users/{username}/installation", {
username: parsedOwner,
request: {
hook: auth5.hook
}
});
const authentication = await auth5({
type: "installation",
installationId: response.data.id,
permissions
});
const installationId = response.data.id;
const appSlug = response.data["app_slug"];
return { authentication, installationId, appSlug };
return createInstallationAuthResult(auth5, response.data, permissions);
}
async function getTokenFromRepository(request2, auth5, parsedOwner, parsedRepositoryNames, permissions) {
const response = await request2("GET /repos/{owner}/{repo}/installation", {
Expand All @@ -23260,15 +23291,28 @@ async function getTokenFromRepository(request2, auth5, parsedOwner, parsedReposi
hook: auth5.hook
}
});
const authentication = await auth5({
type: "installation",
installationId: response.data.id,
repositoryNames: parsedRepositoryNames,
permissions
return createInstallationAuthResult(auth5, response.data, permissions, {
repositoryNames: parsedRepositoryNames
});
const installationId = response.data.id;
const appSlug = response.data["app_slug"];
return { authentication, installationId, appSlug };
}
async function getTokenFromEnterprise(request2, auth5, enterprise, permissions) {
let response;
try {
response = await request2("GET /enterprises/{enterprise}/installation", {
enterprise,
request: {
hook: auth5.hook
}
});
} catch (error2) {
if (error2.status === 404) {
throw new Error(
`No enterprise installation found matching the name ${enterprise}.`
);
}
throw error2;
}
return createInstallationAuthResult(auth5, response.data, permissions);
}

// lib/request.js
Expand Down Expand Up @@ -23309,13 +23353,15 @@ async function run() {
ensureNativeProxySupport();
const appId = getInput("app-id");
const privateKey = getInput("private-key");
const enterprise = getInput("enterprise");
const owner = getInput("owner");
const repositories = getInput("repositories").split(/[\n,]+/).map((s) => s.trim()).filter((x) => x !== "");
const skipTokenRevoke = getBooleanInput("skip-token-revoke");
const permissions = getPermissionsFromInputs(process.env);
return main(
appId,
privateKey,
enterprise,
owner,
repositories,
permissions,
Expand Down
Loading
Loading