Skip to content
Open
Show file tree
Hide file tree
Changes from 22 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-slug: 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-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-slug:
description: "Enterprise slug 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
110 changes: 80 additions & 30 deletions dist/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -23153,47 +23153,67 @@ async function pRetry(input, options = {}) {
}

// lib/main.js
async function main(appId, privateKey, owner, repositories, permissions, core, createAppAuth2, request2, skipTokenRevoke) {
async function main(appId, privateKey, enterpriseSlug, owner, repositories, permissions, core, createAppAuth2, request2, skipTokenRevoke) {
if (enterpriseSlug && (owner || repositories.length > 0)) {
throw new Error("Cannot use 'enterprise-slug' input with 'owner' or 'repositories' inputs");
}
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) => `
if (!enterpriseSlug) {
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:
);
}
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("")}`
);
);
}
} else {
core.info(`Creating enterprise installation token for enterprise "${enterpriseSlug}".`);
}
const auth5 = createAppAuth2({
appId,
privateKey,
request: request2
});
let authentication, installationId, appSlug;
if (parsedRepositoryNames.length > 0) {
if (enterpriseSlug) {
({ authentication, installationId, appSlug } = await pRetry(
() => getTokenFromEnterprise(request2, auth5, enterpriseSlug, permissions),
{
shouldRetry: ({ error: error2 }) => error2.status >= 500,
onFailedAttempt: (context) => {
core.info(
`Failed to create token for enterprise "${enterpriseSlug}" (attempt ${context.attemptNumber}): ${context.error.message}`
);
},
retries: 3
}
));
} else if (parsedRepositoryNames.length > 0) {
({ authentication, installationId, appSlug } = await pRetry(
() => getTokenFromRepository(
request2,
Expand Down Expand Up @@ -23270,6 +23290,32 @@ async function getTokenFromRepository(request2, auth5, parsedOwner, parsedReposi
const appSlug = response.data["app_slug"];
return { authentication, installationId, appSlug };
}
async function getTokenFromEnterprise(request2, auth5, enterpriseSlug, permissions) {
let response;
try {
response = await request2("GET /enterprises/{enterprise}/installation", {
enterprise: enterpriseSlug,
request: {
hook: auth5.hook
}
});
} catch (error2) {
if (error2.status === 404) {
throw new Error(
`No enterprise installation found matching the name ${enterpriseSlug}.`
);
}
throw error2;
}
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 };
}

// lib/request.js
var baseUrl = getInput("github-api-url").replace(/\/$/, "");
Expand Down Expand Up @@ -23309,13 +23355,15 @@ async function run() {
ensureNativeProxySupport();
const appId = getInput("app-id");
const privateKey = getInput("private-key");
const enterpriseSlug = getInput("enterprise-slug");
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,
enterpriseSlug,
owner,
repositories,
permissions,
Expand All @@ -23327,7 +23375,9 @@ async function run() {
}
var main_default = run().catch((error2) => {
console.error(error2);
setFailed(error2.message);
if (process.env.GITHUB_OUTPUT !== void 0) {
setFailed(error2.message);
}
});
/*! Bundled license information:

Expand Down
146 changes: 102 additions & 44 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} enterpriseSlug
* @param {string} owner
* @param {string[]} repositories
* @param {undefined | Record<string, string>} permissions
Expand All @@ -15,58 +16,69 @@ import pRetry from "p-retry";
export async function main(
appId,
privateKey,
enterpriseSlug,
owner,
repositories,
permissions,
core,
createAppAuth,
request,
skipTokenRevoke
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}).`
);
}

// 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-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:
// Skip owner/repository parsing if enterprise-slug is set
if (!enterpriseSlug) {
// 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 "${enterpriseSlug}".`);
}

const auth = createAppAuth({
Expand All @@ -76,9 +88,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-slug is set, get installation ID from the enterprise
if (enterpriseSlug) {
({ authentication, installationId, appSlug } = await pRetry(
() => getTokenFromEnterprise(request, auth, enterpriseSlug, permissions),
{
shouldRetry: ({ error }) => error.status >= 500,
onFailedAttempt: (context) => {
core.info(
`Failed to create token for enterprise "${enterpriseSlug}" (attempt ${context.attemptNumber}): ${context.error.message}`
);
},
retries: 3,
}
));
} else if (parsedRepositoryNames.length > 0) {
({ authentication, installationId, appSlug } = await pRetry(
() =>
getTokenFromRepository(
Expand Down Expand Up @@ -181,3 +206,36 @@ async function getTokenFromRepository(

return { authentication, installationId, appSlug };
}

async function getTokenFromEnterprise(request, auth, enterpriseSlug, permissions) {
let response;
try {
response = await request("GET /enterprises/{enterprise}/installation", {
enterprise: enterpriseSlug,
request: {
hook: auth.hook,
},
});
} catch (error) {
/* c8 ignore next 8 */
if (error.status === 404) {
throw new Error(
`No enterprise installation found matching the name ${enterpriseSlug}.`
);
}

throw error;
}

// Get token for the enterprise installation
const authentication = await auth({
type: "installation",
installationId: response.data.id,
permissions,
});

const installationId = response.data.id;
const appSlug = response.data["app_slug"];

return { authentication, installationId, appSlug };
}
Loading
Loading