952a2a7073
This pull request adds support for generating GitHub App installation
tokens for enterprise-level installations.
### What changed
- Added a new `enterprise` input to `action.yml`.
- Wired `enterprise` through `main.js` and `lib/main.js`.
- Added validation so `enterprise` cannot be combined with `owner` or
`repositories`.
- Implemented enterprise installation lookup using the direct GitHub API
route `GET /enterprises/{enterprise}/installation`, then used the
returned installation ID to mint an installation token through
`@octokit/auth-app`.
- Updated `README.md` with enterprise installation usage and input
documentation.
- Updated `dist/main.cjs` for the bundled action.
- Shared token creation retry behavior across repository, owner, and
enterprise paths so server errors and transient network errors are
retried, while client errors fail immediately.
### Tests
Added focused test coverage for:
- enterprise token creation
- enterprise token creation with explicit permissions
- enterprise installation not found
- mutual exclusivity with `owner`
- mutual exclusivity with `repositories`
- owner installation client errors are not retried
- transient network errors are retried during token creation
### Notes
- This keeps the existing repository-scoped token behavior unchanged.
- Owner, repository, and enterprise token creation now share the same
retry policy: server errors and recognized transient network errors are
retried, while client errors fail immediately. This intentionally fixes
the previous owner-path behavior that retried client errors.
Refs:
-
https://github.blog/changelog/2025-07-01-enterprise-level-access-for-github-apps-and-installation-automation-apis/
-
https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#get-an-enterprise-installation-for-the-authenticated-app
---------
Co-authored-by: Parker Brown <17183625+parkerbxyz@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
238 lines
6.5 KiB
JavaScript
238 lines
6.5 KiB
JavaScript
import pRetry from "p-retry";
|
|
import isNetworkError from "is-network-error";
|
|
// @ts-check
|
|
|
|
/**
|
|
* @param {string} clientId
|
|
* @param {string} privateKey
|
|
* @param {string} enterprise
|
|
* @param {string} owner
|
|
* @param {string[]} repositories
|
|
* @param {undefined | Record<string, string>} permissions
|
|
* @param {import("@actions/core")} core
|
|
* @param {import("@octokit/auth-app").createAppAuth} createAppAuth
|
|
* @param {import("@octokit/request").request} request
|
|
* @param {boolean} skipTokenRevoke
|
|
*/
|
|
export async function main(
|
|
clientId,
|
|
privateKey,
|
|
enterprise,
|
|
owner,
|
|
repositories,
|
|
permissions,
|
|
core,
|
|
createAppAuth,
|
|
request,
|
|
skipTokenRevoke,
|
|
) {
|
|
// 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");
|
|
}
|
|
|
|
const target = resolveInstallationTarget(enterprise, owner, repositories, core);
|
|
|
|
const auth = createAppAuth({
|
|
appId: clientId,
|
|
privateKey,
|
|
request,
|
|
});
|
|
|
|
const { authentication, installationId, appSlug } = await pRetry(
|
|
() => getTokenFromTarget(request, auth, target, permissions),
|
|
createTokenRetryOptions(core, getTokenRetryDescription(target))
|
|
);
|
|
|
|
// Register the token with the runner as a secret to ensure it is masked in logs
|
|
core.setSecret(authentication.token);
|
|
|
|
core.setOutput("token", authentication.token);
|
|
core.setOutput("installation-id", installationId);
|
|
core.setOutput("app-slug", appSlug);
|
|
|
|
// Make token accessible to post function (so we can invalidate it)
|
|
if (!skipTokenRevoke) {
|
|
core.saveState("token", authentication.token);
|
|
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) => `\n- ${parsedOwner}/${repo}`)
|
|
.join("")}`
|
|
);
|
|
} else {
|
|
core.info(
|
|
`Inputs 'owner' and 'repositories' are set. Creating token for the following repositories:${repositories
|
|
.map((repo) => `\n- ${parsedOwner}/${repo}`)
|
|
.join("")}`
|
|
);
|
|
}
|
|
|
|
return {
|
|
type: "repository",
|
|
owner: parsedOwner,
|
|
repositories,
|
|
};
|
|
}
|
|
|
|
function getTokenRetryDescription(target) {
|
|
switch (target.type) {
|
|
case "enterprise":
|
|
return `enterprise "${target.enterprise}"`;
|
|
case "repository":
|
|
return `"${target.repositories
|
|
.map((repository) => `${target.owner}/${repository}`)
|
|
.join(",")}"`;
|
|
case "owner":
|
|
return `"${target.owner}"`;
|
|
/* c8 ignore next 2 */
|
|
default:
|
|
throw new Error(`Unsupported installation target type: ${target.type}`);
|
|
}
|
|
}
|
|
|
|
function getTokenFromTarget(request, auth, target, permissions) {
|
|
switch (target.type) {
|
|
case "enterprise":
|
|
return getTokenFromEnterprise(request, auth, target.enterprise, permissions);
|
|
case "repository":
|
|
return getTokenFromRepository(
|
|
request,
|
|
auth,
|
|
target.owner,
|
|
target.repositories,
|
|
permissions
|
|
);
|
|
case "owner":
|
|
return getTokenFromOwner(request, auth, target.owner, permissions);
|
|
/* c8 ignore next 2 */
|
|
default:
|
|
throw new Error(`Unsupported installation target type: ${target.type}`);
|
|
}
|
|
}
|
|
|
|
function createTokenRetryOptions(core, targetDescription) {
|
|
return {
|
|
shouldRetry: ({ error }) => error.status >= 500 || isNetworkError(error),
|
|
onFailedAttempt: (context) => {
|
|
core.info(
|
|
`Failed to create token for ${targetDescription} (attempt ${context.attemptNumber}): ${context.error.message}`
|
|
);
|
|
},
|
|
retries: 3,
|
|
};
|
|
}
|
|
|
|
async function createInstallationAuthResult(
|
|
auth,
|
|
installation,
|
|
permissions,
|
|
options = {},
|
|
) {
|
|
const authentication = await auth({
|
|
type: "installation",
|
|
installationId: installation.id,
|
|
permissions,
|
|
...options,
|
|
});
|
|
|
|
return {
|
|
authentication,
|
|
installationId: installation.id,
|
|
appSlug: installation["app_slug"],
|
|
};
|
|
}
|
|
|
|
async function getTokenFromOwner(request, auth, parsedOwner, permissions) {
|
|
// https://docs.github.com/rest/apps/apps?apiVersion=2022-11-28#get-a-user-installation-for-the-authenticated-app
|
|
// This endpoint works for both users and organizations
|
|
const response = await request("GET /users/{username}/installation", {
|
|
username: parsedOwner,
|
|
request: {
|
|
hook: auth.hook,
|
|
},
|
|
});
|
|
|
|
// Get token for all repositories of the given installation
|
|
return createInstallationAuthResult(auth, response.data, permissions);
|
|
}
|
|
|
|
async function getTokenFromRepository(
|
|
request,
|
|
auth,
|
|
parsedOwner,
|
|
parsedRepositoryNames,
|
|
permissions
|
|
) {
|
|
// https://docs.github.com/rest/apps/apps?apiVersion=2022-11-28#get-a-repository-installation-for-the-authenticated-app
|
|
const response = await request("GET /repos/{owner}/{repo}/installation", {
|
|
owner: parsedOwner,
|
|
repo: parsedRepositoryNames[0],
|
|
request: {
|
|
hook: auth.hook,
|
|
},
|
|
});
|
|
|
|
// Get token for given repositories
|
|
return createInstallationAuthResult(auth, response.data, permissions, {
|
|
repositoryNames: parsedRepositoryNames,
|
|
});
|
|
}
|
|
|
|
async function getTokenFromEnterprise(request, auth, enterprise, permissions) {
|
|
let response;
|
|
try {
|
|
response = await request("GET /enterprises/{enterprise}/installation", {
|
|
enterprise,
|
|
request: {
|
|
hook: auth.hook,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (error.status === 404) {
|
|
throw new Error(
|
|
`No enterprise installation found matching the enterprise slug "${enterprise}".`
|
|
);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
|
|
// Get token for the enterprise installation
|
|
return createInstallationAuthResult(auth, response.data, permissions);
|
|
}
|