Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
42 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
60ab571
Merge origin/main into enterprise-app-enterprise-slug
parkerbxyz Apr 30, 2026
806ce0a
test: simplify enterprise exclusivity tests
parkerbxyz Apr 30, 2026
22a239f
docs: use client-id in enterprise example
parkerbxyz Apr 30, 2026
9260060
fix: align owner token retry behavior
parkerbxyz Apr 30, 2026
d3503d1
Merge branch 'main' into main
parkerbxyz May 8, 2026
b4b15fc
build: rebuild main bundle from lockfile
parkerbxyz May 8, 2026
1c43a39
Merge remote-tracking branch 'theztefan/main' into enterprise-app-ent…
parkerbxyz May 8, 2026
d0ac922
refactor: centralize token target dispatch
parkerbxyz May 8, 2026
6d2a54a
docs: clarify enterprise token inputs
parkerbxyz May 8, 2026
6cc1810
test: use generated enterprise permission input
parkerbxyz May 8, 2026
e9a267e
test: remove misleading enterprise permission case
parkerbxyz May 8, 2026
a109b1c
test: restore enterprise permission forwarding coverage
parkerbxyz May 8, 2026
4ba4a13
fix: include owner in repository retry logs
parkerbxyz May 8, 2026
6f5d39f
fix: retry transient token network errors
parkerbxyz May 8, 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 thread
parkerbxyz marked this conversation as resolved.
Comment thread
parkerbxyz marked this conversation as resolved.
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