From cbc2930e9b595ab8664f553d85e033755e781180 Mon Sep 17 00:00:00 2001 From: Stefan Petrushevski Date: Tue, 8 Jul 2025 15:04:32 +0200 Subject: [PATCH 01/37] enterprise input; logic to generate ent token --- action.yml | 3 + dist/main.cjs | 104 ++++++++++++++++++++++++---------- lib/main.js | 151 +++++++++++++++++++++++++++++++++++--------------- main.js | 2 + 4 files changed, 185 insertions(+), 75 deletions(-) diff --git a/action.yml b/action.yml index ab7d7f30..6ed61cf9 100644 --- a/action.yml +++ b/action.yml @@ -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 diff --git a/dist/main.cjs b/dist/main.cjs index 905c9fb5..434f543e 100644 --- a/dist/main.cjs +++ b/dist/main.cjs @@ -42523,39 +42523,46 @@ 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, @@ -42563,7 +42570,20 @@ async function main(appId2, privateKey2, owner2, repositories2, permissions2, co 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, @@ -42640,6 +42660,28 @@ 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" + ); + if (!enterpriseInstallation) { + throw new Error(`No enterprise installation found. Available installations: ${response.data.map((i) => `${i.target_type}:${i.account?.login || "N/A"}`).join(", ")}`); + } + console.info(`### Found enterprise installation: ${JSON.stringify(enterpriseInstallation, null, 2)}`); + 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); @@ -42677,6 +42719,7 @@ 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"); @@ -42684,6 +42727,7 @@ var permissions = getPermissionsFromInputs(process.env); var main_default = main( appId, privateKey, + enterprise, owner, repositories, permissions, diff --git a/lib/main.js b/lib/main.js index 3ec39b50..475f2335 100644 --- a/lib/main.js +++ b/lib/main.js @@ -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} permissions @@ -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, - // 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({ @@ -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( @@ -181,3 +207,38 @@ 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, + }, + }); + + // Find the enterprise installation + const enterpriseInstallation = response.data.find( + installation => installation.target_type === "Enterprise" + ); + + if (!enterpriseInstallation) { + throw new Error(`No enterprise installation found. Available installations: ${response.data.map(i => `${i.target_type}:${i.account?.login || 'N/A'}`).join(', ')}`); + } + + console.info(`### Found enterprise installation: ${JSON.stringify(enterpriseInstallation, null, 2)}`); + + // 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 }; +} diff --git a/main.js b/main.js index 76703784..a53f7406 100644 --- a/main.js +++ b/main.js @@ -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") @@ -32,6 +33,7 @@ const permissions = getPermissionsFromInputs(process.env); export default main( appId, privateKey, + enterprise, owner, repositories, permissions, From 55b8c24e8dc0675d17bf34be98016c9a258907c3 Mon Sep 17 00:00:00 2001 From: Stefan Petrushevski Date: Tue, 8 Jul 2025 17:05:18 +0200 Subject: [PATCH 02/37] tests; update README --- README.md | 29 ++++++ ...-enterprise-installation-not-found.test.js | 39 ++++++++ ...enterprise-mutual-exclusivity-both.test.js | 16 +++ ...nterprise-mutual-exclusivity-owner.test.js | 15 +++ ...se-mutual-exclusivity-repositories.test.js | 15 +++ tests/main-enterprise-only-success.test.js | 34 +++++++ tests/main-enterprise-token-success.test.js | 34 +++++++ ...-enterprise-token-with-permissions.test.js | 36 +++++++ tests/snapshots/index.js.md | 93 ++++++++++++++++++ tests/snapshots/index.js.snap | Bin 1388 -> 1589 bytes 10 files changed, 311 insertions(+) create mode 100644 tests/main-enterprise-installation-not-found.test.js create mode 100644 tests/main-enterprise-mutual-exclusivity-both.test.js create mode 100644 tests/main-enterprise-mutual-exclusivity-owner.test.js create mode 100644 tests/main-enterprise-mutual-exclusivity-repositories.test.js create mode 100644 tests/main-enterprise-only-success.test.js create mode 100644 tests/main-enterprise-token-success.test.js create mode 100644 tests/main-enterprise-token-with-permissions.test.js diff --git a/README.md b/README.md index f969916f..43379320 100644 --- a/README.md +++ b/README.md @@ -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] @@ -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-` **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`). diff --git a/tests/main-enterprise-installation-not-found.test.js b/tests/main-enterprise-installation-not-found.test.js new file mode 100644 index 00000000..ca27b31d --- /dev/null +++ b/tests/main-enterprise-installation-not-found.test.js @@ -0,0 +1,39 @@ +import { test } from "./main.js"; +delete process.env.INPUT_OWNER; +delete process.env.INPUT_REPOSITORIES; + +// Verify `main` handles when no enterprise installation is found. +await test((mockPool) => { + process.env.INPUT_ENTERPRISE = "test-enterprise"; + + + // Mock the /app/installations endpoint to return only non-enterprise installations + mockPool + .intercept({ + path: "/app/installations", + method: "GET", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": "actions/create-github-app-token", + // Intentionally omitting the `authorization` header, since JWT creation is not idempotent. + }, + }) + .reply( + 200, + [ + { + id: "111111", + app_slug: "github-actions", + target_type: "Organization", + account: { login: "some-org" } + }, + { + id: "222222", + app_slug: "github-actions", + target_type: "User", + account: { login: "some-user" } + } + ], + { headers: { "content-type": "application/json" } } + ); +}); diff --git a/tests/main-enterprise-mutual-exclusivity-both.test.js b/tests/main-enterprise-mutual-exclusivity-both.test.js new file mode 100644 index 00000000..f4b5e3bb --- /dev/null +++ b/tests/main-enterprise-mutual-exclusivity-both.test.js @@ -0,0 +1,16 @@ +import { DEFAULT_ENV } from "./main.js"; + +// Verify `main` exits with an error when `enterprise` is used with both `owner` and `repositories` inputs. +try { + // Set up environment with enterprise, owner, and repositories all set + for (const [key, value] of Object.entries(DEFAULT_ENV)) { + process.env[key] = value; + } + process.env.INPUT_ENTERPRISE = "test-enterprise"; + process.env.INPUT_OWNER = "test-owner"; + process.env.INPUT_REPOSITORIES = "repo1,repo2"; + + await import("../main.js"); +} catch (error) { + console.error(error.message); +} diff --git a/tests/main-enterprise-mutual-exclusivity-owner.test.js b/tests/main-enterprise-mutual-exclusivity-owner.test.js new file mode 100644 index 00000000..59ec6373 --- /dev/null +++ b/tests/main-enterprise-mutual-exclusivity-owner.test.js @@ -0,0 +1,15 @@ +import { DEFAULT_ENV } from "./main.js"; + +// Verify `main` exits with an error when `enterprise` is used with `owner` input. +try { + // Set up environment with enterprise and owner both set + for (const [key, value] of Object.entries(DEFAULT_ENV)) { + process.env[key] = value; + } + process.env.INPUT_ENTERPRISE = "test-enterprise"; + process.env.INPUT_OWNER = "test-owner"; + + await import("../main.js"); +} catch (error) { + console.error(error.message); +} diff --git a/tests/main-enterprise-mutual-exclusivity-repositories.test.js b/tests/main-enterprise-mutual-exclusivity-repositories.test.js new file mode 100644 index 00000000..893c7de0 --- /dev/null +++ b/tests/main-enterprise-mutual-exclusivity-repositories.test.js @@ -0,0 +1,15 @@ +import { DEFAULT_ENV } from "./main.js"; + +// Verify `main` exits with an error when `enterprise` is used with `repositories` input. +try { + // Set up environment with enterprise and repositories both set + for (const [key, value] of Object.entries(DEFAULT_ENV)) { + process.env[key] = value; + } + process.env.INPUT_ENTERPRISE = "test-enterprise"; + process.env.INPUT_REPOSITORIES = "repo1,repo2"; + + await import("../main.js"); +} catch (error) { + console.error(error.message); +} diff --git a/tests/main-enterprise-only-success.test.js b/tests/main-enterprise-only-success.test.js new file mode 100644 index 00000000..dae89bc9 --- /dev/null +++ b/tests/main-enterprise-only-success.test.js @@ -0,0 +1,34 @@ +import { test } from "./main.js"; + +// Verify `main` successfully obtains a token when only the `enterprise` input is set. +await test((mockPool) => { + process.env.INPUT_ENTERPRISE = "test-enterprise"; + delete process.env.INPUT_OWNER; + delete process.env.INPUT_REPOSITORIES; + + // Mock the /app/installations endpoint to return an enterprise installation + const mockInstallationId = "123456"; + const mockAppSlug = "github-actions"; + mockPool + .intercept({ + path: "/app/installations", + method: "GET", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": "actions/create-github-app-token", + // Intentionally omitting the `authorization` header, since JWT creation is not idempotent. + }, + }) + .reply( + 200, + [ + { + id: mockInstallationId, + app_slug: mockAppSlug, + target_type: "Enterprise", + account: { login: "test-enterprise" } + } + ], + { headers: { "content-type": "application/json" } } + ); +}); diff --git a/tests/main-enterprise-token-success.test.js b/tests/main-enterprise-token-success.test.js new file mode 100644 index 00000000..197348e5 --- /dev/null +++ b/tests/main-enterprise-token-success.test.js @@ -0,0 +1,34 @@ +import { test } from "./main.js"; + +// Verify `main` successfully generates enterprise token with basic functionality. +await test((mockPool) => { + process.env.INPUT_ENTERPRISE = "test-enterprise"; + delete process.env.INPUT_OWNER; + delete process.env.INPUT_REPOSITORIES; + + // Mock the /app/installations endpoint to return an enterprise installation + const mockInstallationId = "123456"; + const mockAppSlug = "github-actions"; + mockPool + .intercept({ + path: "/app/installations", + method: "GET", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": "actions/create-github-app-token", + // Intentionally omitting the `authorization` header, since JWT creation is not idempotent. + }, + }) + .reply( + 200, + [ + { + id: mockInstallationId, + app_slug: mockAppSlug, + target_type: "Enterprise", + account: { login: "test-enterprise" } + } + ], + { headers: { "content-type": "application/json" } } + ); +}); diff --git a/tests/main-enterprise-token-with-permissions.test.js b/tests/main-enterprise-token-with-permissions.test.js new file mode 100644 index 00000000..ab2a160c --- /dev/null +++ b/tests/main-enterprise-token-with-permissions.test.js @@ -0,0 +1,36 @@ +import { test } from "./main.js"; + +// Verify `main` successfully generates enterprise token with specific permissions. +await test((mockPool) => { + process.env.INPUT_ENTERPRISE = "test-enterprise"; + delete process.env.INPUT_OWNER; + delete process.env.INPUT_REPOSITORIES; + process.env["INPUT_PERMISSION-ENTERPRISE-ORGANIZATIONS"] = "read"; + process.env["INPUT_PERMISSION-ENTERPRISE-PEOPLE"] = "write"; + + // Mock the /app/installations endpoint to return an enterprise installation + const mockInstallationId = "123456"; + const mockAppSlug = "github-actions"; + mockPool + .intercept({ + path: "/app/installations", + method: "GET", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": "actions/create-github-app-token", + // Intentionally omitting the `authorization` header, since JWT creation is not idempotent. + }, + }) + .reply( + 200, + [ + { + id: mockInstallationId, + app_slug: mockAppSlug, + target_type: "Enterprise", + account: { login: "test-enterprise" } + } + ], + { headers: { "content-type": "application/json" } } + ); +}); diff --git a/tests/snapshots/index.js.md b/tests/snapshots/index.js.md index e419536b..dfd74737 100644 --- a/tests/snapshots/index.js.md +++ b/tests/snapshots/index.js.md @@ -39,6 +39,99 @@ Generated by [AVA](https://avajs.dev). POST /api/v3/app/installations/123456/access_tokens␊ {"repositories":["create-github-app-token"]}` +## main-enterprise-only-success.test.js + +> stderr + + '' + +> stdout + + `Creating enterprise installation token for enterprise "test-enterprise".␊ + ### Found enterprise installation: {␊ + "id": "123456",␊ + "app_slug": "github-actions",␊ + "target_type": "Enterprise",␊ + "account": {␊ + "login": "test-enterprise"␊ + }␊ + }␊ + ::add-mask::ghs_16C7e42F292c6912E7710c838347Ae178B4a␊ + ␊ + ::set-output name=token::ghs_16C7e42F292c6912E7710c838347Ae178B4a␊ + ␊ + ::set-output name=installation-id::123456␊ + ␊ + ::set-output name=app-slug::github-actions␊ + ::save-state name=token::ghs_16C7e42F292c6912E7710c838347Ae178B4a␊ + ::save-state name=expiresAt::2016-07-11T22:14:10Z␊ + --- REQUESTS ---␊ + GET /app/installations␊ + POST /app/installations/123456/access_tokens␊ + null` + +## main-enterprise-token-success.test.js + +> stderr + + '' + +> stdout + + `Creating enterprise installation token for enterprise "test-enterprise".␊ + ### Found enterprise installation: {␊ + "id": "123456",␊ + "app_slug": "github-actions",␊ + "target_type": "Enterprise",␊ + "account": {␊ + "login": "test-enterprise"␊ + }␊ + }␊ + ::add-mask::ghs_16C7e42F292c6912E7710c838347Ae178B4a␊ + ␊ + ::set-output name=token::ghs_16C7e42F292c6912E7710c838347Ae178B4a␊ + ␊ + ::set-output name=installation-id::123456␊ + ␊ + ::set-output name=app-slug::github-actions␊ + ::save-state name=token::ghs_16C7e42F292c6912E7710c838347Ae178B4a␊ + ::save-state name=expiresAt::2016-07-11T22:14:10Z␊ + --- REQUESTS ---␊ + GET /app/installations␊ + POST /app/installations/123456/access_tokens␊ + null` + +## main-enterprise-token-with-permissions.test.js + +> stderr + + '' + +> stdout + + `Creating enterprise installation token for enterprise "test-enterprise".␊ + ### Found enterprise installation: {␊ + "id": "123456",␊ + "app_slug": "github-actions",␊ + "target_type": "Enterprise",␊ + "account": {␊ + "login": "test-enterprise"␊ + }␊ + }␊ + ::add-mask::ghs_16C7e42F292c6912E7710c838347Ae178B4a␊ + ␊ + ::set-output name=token::ghs_16C7e42F292c6912E7710c838347Ae178B4a␊ + ␊ + ::set-output name=installation-id::123456␊ + ␊ + ::set-output name=app-slug::github-actions␊ + ::save-state name=token::ghs_16C7e42F292c6912E7710c838347Ae178B4a␊ + ::save-state name=expiresAt::2016-07-11T22:14:10Z␊ + --- REQUESTS ---␊ + GET /app/installations␊ + POST /app/installations/123456/access_tokens␊ + {"permissions":{"enterprise_organizations":"read","enterprise_people":"write"}}` + ## main-missing-owner.test.js > stderr diff --git a/tests/snapshots/index.js.snap b/tests/snapshots/index.js.snap index 773f4b18b4e1aa064fd7944d242c0276c527e30b..edde1648817a03f4f23319d5c9aa3aedc6eb310c 100644 GIT binary patch literal 1589 zcmV-52Fm$CRzVP6nIDS?00000000B+n%!<2MHI)=AcV9C3GNUe7)^_ug2#@XkdIYCv{jnsf~F*? zNLy9bc=y;Iyq;NRX6>3NLPFvii5u>a+Be_{cmp1Q2SDNhxZ?uNtUq>B+ex!YTea(} z#GX0l%*_9HX3y+AYSaVawbieWK{Fk|Lqt8z1SdX(5zE3s%>b1Ey=FI29>vQkSK|t#eG?ey1%GnJ>q=#N{hcZsQNNLd_O{QB>ozRex zNCv0(dw2e)WRv;s{FWUFDu^BjSwNKgs30K}ifJJkr~;xAkOEyo*CkNRcoS*S296p+ zBHaSi5J4b%=KEwn*FpByO?0c#13h;BcbiFImL{*C0d56cE9KK2eeNl11~sr8NKZ{7A@S zUnn`iguoLmu!$fM9xgc-FJ449M8y4RmRxk;AcUEZU4-LV!$tEA4a1sIkNG~WpZIr8 zWfOEw_d_sEHpX7#?w%(iu5oN1AsmP%|zPj$Hioe5JVUNc5zQK4ft^K!`FM3FzbXjiLC~v(zL&sWaYy8=GNZLJJ)JE z8@IQ2xAwMo?$)-yxwWx_2G)wG6Df`Awv0eppu!bv|BJ%uDx;55p^hFp`_tpSnTpqa zBHm*oEnhKiP8)EXy+X;l=5e?U{Wx)f@@NP?;m`{hhfLXvrLxz(6P9&c=ozIQQ{#%R zj~0$8ATF2k{Bd6SBSQuEOiH(|6}YiMR2zEJLHsu*)4%5mTS`UXBdQC?3n=TLg#x3x zaCrnGXfs`=k#3oh zJV2XF-;C<$>g}!AgrP(Am4?pXe+kHEjRr{I8rAz~X%Q*#1ou@5?SPj3__X7@2$V*K zWgEb-iw9`1^@49uDorHzhtyPr&+;IqL0JI4U9X(kXTxk_%F~=aP1}r2C+to;f{Ayk z_~E)~=G=&pnAta-i3Al2t{}@!)lKQX8SDP2)R~&OH_;$SX&jk2eM7412(KML$+=+% zYxeLsl_h%PK_iS7sMgR4HL6^8lUc#|rd`ScS`Q#cNbjQ4)K|VBS6lg0li3)?E7be!UvyyL~b@Qw{E$fowM4vhD z=5OnAfj>uqOPaFqow81}iPHfDX=H!!<`wyO2 n9&@E&;ALVo?@o0`5|6xdj==`ve% znh@Rir5}q300000000B+TG4JBMHEef5RwrRydgj^S|M!;89R1D(u@istlvcL44oHp1qSjxjxw6#Gs6s6}^~$Z?{c(RD$GLw^EzL@e2LTv!?E0I^ z120>DkKOgA4CUQ>IFa!mImY*dF+#SfBji-3A8u!PJioEMb8zGK)#JUbTf6%^2fKSm z$Gbn=+}b0fXiKD*7$i-{WP*zLS6uvW{PT(PvmDgZc@L-Ky^+Jqp2B-3(%Ka}%y|J< z9pG4tzO6_XhIAmXL%{?*hLB1aL_$IVM>|>&IcK@^jLsqz&TtoD#-=hPi)V27QnmDw zi}I2JCh}6;qwffAV^GtD!E_=1lcVY1bN-Go350~BPlAYx9$EASNB`2P5V<5f5}^&+ zl2L;JnG&@F1Vs-Bb~w6U+!)Yyl4QUQ5iW4Ga`#&mpy~>&@dP_|Cilp;z#B=EY~I>Q zLs)f~p+V@4erq71Xth9tMA96Rm1SZeP%<=CvInS#>4N)H0tSiIvIB6^B>=*MKqV3( zv{I^0RP4{WRKzdyUZBNA1fi=}so9sK*-XmwI(?qD1vQcaybicxq*t-rlH@ zM;19E^kO9SODyXOc5L3~BI3;mN{L*QL=pqq)JD~qAd4CQKbI6_5=9f~zA}~WZY_IZ zoc}Aw`9EKk*U!>K)*MP*luO5RFC81RwPe4bqCGB$CmWt3iuxm`sK>9vlaz4_R4VEU z%=YO(&{YYRntAiLNK4+R{@#*zQuk@1sXw!5O0kq;`Janr`I*F`iY+XIoK`xogH}oz zyzw%a!uT&|Gd_`)cLm7)ZjS8#&cE2b_$aaEbKwi#K%Wr!0yfB#3FvrtYPv-T_~b*- zo9fhQ-6=1Lwd#uy!Ya9K4`@gTEuKW^NT!KDnz}!6Pdm$eOV=%3cbel0woatc*;4 zImbavv8B}u3!qxdp;}C#jKVGv(-m<%mf;xU#Do6~utM%}_;C)@d*l3cAv`lD4s)TL ue32Xelc&mKUnv-kG7-&(88iiN4-P-gVR=8rGTw#d#{UoZe*3L3DF6V^n5cyS From 3c69395e16a83d2fa36ebe598ccaa91b6d00cdae Mon Sep 17 00:00:00 2001 From: Stefan Petrushevski Date: Tue, 8 Jul 2025 17:22:19 +0200 Subject: [PATCH 03/37] update package version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 89a8e937..6a6de428 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "create-github-app-token", "private": true, "type": "module", - "version": "2.0.6", + "version": "2.0.7", "description": "GitHub Action for creating a GitHub App Installation Access Token", "scripts": { "build": "esbuild main.js post.js --bundle --outdir=dist --out-extension:.js=.cjs --platform=node --target=node20.0.0 --packages=bundle", From 46f9f788b8824135a13ac16ab36abcba846e99a5 Mon Sep 17 00:00:00 2001 From: Stefan Petrushevski Date: Tue, 8 Jul 2025 17:52:25 +0200 Subject: [PATCH 04/37] improve installation match; refactor test per copilot review --- dist/main.cjs | 5 ++--- lib/main.js | 7 +++---- tests/main-enterprise-installation-not-found.test.js | 5 +++-- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/dist/main.cjs b/dist/main.cjs index 434f543e..2931c581 100644 --- a/dist/main.cjs +++ b/dist/main.cjs @@ -42667,12 +42667,11 @@ async function getTokenFromEnterprise(request2, auth5, enterprise2, permissions2 } }); const enterpriseInstallation = response.data.find( - (installation) => installation.target_type === "Enterprise" + (installation) => installation.target_type === "Enterprise" && installation.account?.slug === enterprise2 ); if (!enterpriseInstallation) { - throw new Error(`No enterprise installation found. Available installations: ${response.data.map((i) => `${i.target_type}:${i.account?.login || "N/A"}`).join(", ")}`); + 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(", ")}`); } - console.info(`### Found enterprise installation: ${JSON.stringify(enterpriseInstallation, null, 2)}`); const authentication = await auth5({ type: "installation", installationId: enterpriseInstallation.id, diff --git a/lib/main.js b/lib/main.js index 475f2335..e9c83b0d 100644 --- a/lib/main.js +++ b/lib/main.js @@ -221,15 +221,14 @@ async function getTokenFromEnterprise(request, auth, enterprise, permissions) { // Find the enterprise installation const enterpriseInstallation = response.data.find( - installation => installation.target_type === "Enterprise" + installation => installation.target_type === "Enterprise" && + installation.account?.slug === enterprise ); if (!enterpriseInstallation) { - throw new Error(`No enterprise installation found. Available installations: ${response.data.map(i => `${i.target_type}:${i.account?.login || 'N/A'}`).join(', ')}`); + 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(', ')}`); } - console.info(`### Found enterprise installation: ${JSON.stringify(enterpriseInstallation, null, 2)}`); - // Get token for the enterprise installation const authentication = await auth({ type: "installation", diff --git a/tests/main-enterprise-installation-not-found.test.js b/tests/main-enterprise-installation-not-found.test.js index ca27b31d..c5a492d0 100644 --- a/tests/main-enterprise-installation-not-found.test.js +++ b/tests/main-enterprise-installation-not-found.test.js @@ -1,9 +1,10 @@ import { test } from "./main.js"; -delete process.env.INPUT_OWNER; -delete process.env.INPUT_REPOSITORIES; + // Verify `main` handles when no enterprise installation is found. await test((mockPool) => { + delete process.env.INPUT_OWNER; + delete process.env.INPUT_REPOSITORIES; process.env.INPUT_ENTERPRISE = "test-enterprise"; From 7434028a6d5ad47d0ded03b6aad8050c1172019c Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 28 Aug 2025 09:33:49 +0200 Subject: [PATCH 05/37] Update README.md Co-authored-by: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 43379320..bcd0caa1 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ jobs: with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.PRIVATE_KEY }} - enterprise: my-enterprise-slug + enterprise-slug: my-enterprise-slug - name: Call enterprise management REST API with gh run: | gh api /enterprises/my-enterprise-slug/apps/installable_organizations From 81e8c224dfec777ada582a85f9f10e3c540b0eb7 Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 28 Aug 2025 09:34:01 +0200 Subject: [PATCH 06/37] Update README.md Co-authored-by: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bcd0caa1..485e9e91 100644 --- a/README.md +++ b/README.md @@ -357,12 +357,12 @@ 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` +### `enterprise-slug` **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. +> 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-` From a84c82dc2033793458f78c90d72b7fc36d2da26b Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 28 Aug 2025 09:34:12 +0200 Subject: [PATCH 07/37] Update action.yml Co-authored-by: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 6ed61cf9..a6e4dde2 100644 --- a/action.yml +++ b/action.yml @@ -17,7 +17,7 @@ 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: + enterprise-slug: description: "Enterprise slug for enterprise-level app installations (cannot be used with 'owner' or 'repositories')" required: false skip-token-revoke: From 7b860611c2628b455c2b6b435dd523f6efae6012 Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 28 Aug 2025 09:34:21 +0200 Subject: [PATCH 08/37] Update lib/main.js Co-authored-by: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> --- lib/main.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/main.js b/lib/main.js index e9c83b0d..35919dec 100644 --- a/lib/main.js +++ b/lib/main.js @@ -4,7 +4,7 @@ import pRetry from "p-retry"; /** * @param {string} appId * @param {string} privateKey - * @param {string} enterprise + * @param {string} enterpriseSlug * @param {string} owner * @param {string[]} repositories * @param {undefined | Record} permissions From 3b3f07c3d1fa4f1ec0997ff1399af1de7542480c Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 28 Aug 2025 09:34:29 +0200 Subject: [PATCH 09/37] Update lib/main.js Co-authored-by: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> --- lib/main.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/main.js b/lib/main.js index 35919dec..b073f20e 100644 --- a/lib/main.js +++ b/lib/main.js @@ -16,7 +16,7 @@ import pRetry from "p-retry"; export async function main( appId, privateKey, - enterprise, + enterpriseSlug, owner, repositories, permissions, From 22e6bc6b49324edd8ed06360edce7c79d76f5cdf Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 28 Aug 2025 09:34:44 +0200 Subject: [PATCH 10/37] Update lib/main.js Co-authored-by: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> --- lib/main.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/main.js b/lib/main.js index b073f20e..4b3d352f 100644 --- a/lib/main.js +++ b/lib/main.js @@ -26,9 +26,9 @@ export async function main( 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"); + // 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"); } let parsedOwner = ""; From 6cf7b5f22acbf06cc6851840d0f030709a312f26 Mon Sep 17 00:00:00 2001 From: Stefan Petrushevski Date: Thu, 28 Aug 2025 10:23:07 +0200 Subject: [PATCH 11/37] update tests with enterprise-slug --- .gitignore | 1 + dist/main.cjs | 32 ++++--- lib/main.js | 22 ++--- main.js | 9 +- package-lock.json | 4 +- ...-enterprise-installation-not-found.test.js | 2 +- ...enterprise-mutual-exclusivity-both.test.js | 6 +- ...nterprise-mutual-exclusivity-owner.test.js | 6 +- ...se-mutual-exclusivity-repositories.test.js | 6 +- tests/main-enterprise-only-success.test.js | 6 +- tests/main-enterprise-token-success.test.js | 4 +- ...-enterprise-token-with-permissions.test.js | 4 +- tests/snapshots/index.js.md | 88 +++++++++++++----- tests/snapshots/index.js.snap | Bin 1589 -> 2003 bytes 14 files changed, 118 insertions(+), 72 deletions(-) diff --git a/.gitignore b/.gitignore index b443287f..d5a9b6ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env coverage node_modules/ +.DS_Store \ No newline at end of file diff --git a/dist/main.cjs b/dist/main.cjs index 2931c581..c00f94b6 100644 --- a/dist/main.cjs +++ b/dist/main.cjs @@ -42523,13 +42523,13 @@ async function pRetry(input, options) { } // lib/main.js -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"); +async function main(appId2, privateKey2, enterpriseSlug2, owner2, repositories2, permissions2, core3, createAppAuth2, request2, skipTokenRevoke2) { + if (enterpriseSlug2 && (owner2 || repositories2.length > 0)) { + throw new Error("Cannot use 'enterprise-slug' input with 'owner' or 'repositories' inputs"); } let parsedOwner = ""; let parsedRepositoryNames = []; - if (!enterprise2) { + if (!enterpriseSlug2) { if (!owner2 && repositories2.length === 0) { const [owner3, repo] = String(process.env.GITHUB_REPOSITORY).split("/"); parsedOwner = owner3; @@ -42557,12 +42557,12 @@ async function main(appId2, privateKey2, enterprise2, owner2, repositories2, per parsedRepositoryNames = repositories2; core3.info( `Inputs 'owner' and 'repositories' are set. Creating token for the following repositories: - ${repositories2.map((repo) => ` + ${repositories2.map((repo) => ` - ${parsedOwner}/${repo}`).join("")}` ); } } else { - core3.info(`Creating enterprise installation token for enterprise "${enterprise2}".`); + core3.info(`Creating enterprise installation token for enterprise "${enterpriseSlug2}".`); } const auth5 = createAppAuth2({ appId: appId2, @@ -42570,14 +42570,14 @@ async function main(appId2, privateKey2, enterprise2, owner2, repositories2, per request: request2 }); let authentication, installationId, appSlug; - if (enterprise2) { + if (enterpriseSlug2) { ({ authentication, installationId, appSlug } = await pRetry( - () => getTokenFromEnterprise(request2, auth5, enterprise2, permissions2), + () => getTokenFromEnterprise(request2, auth5, enterpriseSlug2, permissions2), { shouldRetry: (error) => error.status >= 500, onFailedAttempt: (error) => { core3.info( - `Failed to create token for enterprise "${enterprise2}" (attempt ${error.attemptNumber}): ${error.message}` + `Failed to create token for enterprise "${enterpriseSlug2}" (attempt ${error.attemptNumber}): ${error.message}` ); }, retries: 3 @@ -42660,17 +42660,17 @@ async function getTokenFromRepository(request2, auth5, parsedOwner, parsedReposi const appSlug = response.data["app_slug"]; return { authentication, installationId, appSlug }; } -async function getTokenFromEnterprise(request2, auth5, enterprise2, permissions2) { +async function getTokenFromEnterprise(request2, auth5, enterpriseSlug2, 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 + (installation) => installation.target_type === "Enterprise" && installation.account?.slug === enterpriseSlug2 ); 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(", ")}`); + throw new Error(`No enterprise installation found matching the name ${enterpriseSlug2}. Available installations: ${response.data.map((i) => `${i.target_type}:${i.account?.login || "N/A"}`).join(", ")}`); } const authentication = await auth5({ type: "installation", @@ -42718,7 +42718,7 @@ 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 enterpriseSlug = import_core2.default.getInput("enterprise-slug"); 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"); @@ -42726,7 +42726,7 @@ var permissions = getPermissionsFromInputs(process.env); var main_default = main( appId, privateKey, - enterprise, + enterpriseSlug, owner, repositories, permissions, @@ -42736,7 +42736,9 @@ var main_default = main( skipTokenRevoke ).catch((error) => { console.error(error); - import_core2.default.setFailed(error.message); + if (process.env.GITHUB_OUTPUT !== void 0) { + import_core2.default.setFailed(error.message); + } }); /*! Bundled license information: diff --git a/lib/main.js b/lib/main.js index 4b3d352f..8256f811 100644 --- a/lib/main.js +++ b/lib/main.js @@ -34,8 +34,8 @@ export async function main( let parsedOwner = ""; let parsedRepositoryNames = []; - // Skip owner/repository parsing if enterprise is set - if (!enterprise) { + // 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("/"); @@ -75,11 +75,11 @@ export async function main( core.info( `Inputs 'owner' and 'repositories' are set. Creating token for the following repositories: - ${repositories.map((repo) => `\n- ${parsedOwner}/${repo}`).join("")}` + ${repositories.map((repo) => `\n- ${parsedOwner}/${repo}`).join("")}` ); } } else { - core.info(`Creating enterprise installation token for enterprise "${enterprise}".`); + core.info(`Creating enterprise installation token for enterprise "${enterpriseSlug}".`); } const auth = createAppAuth({ @@ -90,15 +90,15 @@ export async function main( let authentication, installationId, appSlug; - // If enterprise is set, get installation ID from the enterprise - if (enterprise) { + // If enterprise-slug is set, get installation ID from the enterprise + if (enterpriseSlug) { ({ authentication, installationId, appSlug } = await pRetry( - () => getTokenFromEnterprise(request, auth, enterprise, permissions), + () => getTokenFromEnterprise(request, auth, enterpriseSlug, permissions), { shouldRetry: (error) => error.status >= 500, onFailedAttempt: (error) => { core.info( - `Failed to create token for enterprise "${enterprise}" (attempt ${error.attemptNumber}): ${error.message}` + `Failed to create token for enterprise "${enterpriseSlug}" (attempt ${error.attemptNumber}): ${error.message}` ); }, retries: 3, @@ -208,7 +208,7 @@ async function getTokenFromRepository( return { authentication, installationId, appSlug }; } -async function getTokenFromEnterprise(request, auth, enterprise, permissions) { +async function getTokenFromEnterprise(request, auth, enterpriseSlug, 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, @@ -222,11 +222,11 @@ async function getTokenFromEnterprise(request, auth, enterprise, permissions) { // Find the enterprise installation const enterpriseInstallation = response.data.find( installation => installation.target_type === "Enterprise" && - installation.account?.slug === enterprise + installation.account?.slug === enterpriseSlug ); 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(', ')}`); + throw new Error(`No enterprise installation found matching the name ${enterpriseSlug}. Available installations: ${response.data.map(i => `${i.target_type}:${i.account?.login || 'N/A'}`).join(', ')}`); } // Get token for the enterprise installation diff --git a/main.js b/main.js index a53f7406..88501493 100644 --- a/main.js +++ b/main.js @@ -17,7 +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 enterpriseSlug = core.getInput("enterprise-slug"); const owner = core.getInput("owner"); const repositories = core .getInput("repositories") @@ -33,7 +33,7 @@ const permissions = getPermissionsFromInputs(process.env); export default main( appId, privateKey, - enterprise, + enterpriseSlug, owner, repositories, permissions, @@ -44,5 +44,8 @@ export default main( ).catch((error) => { /* c8 ignore next 3 */ console.error(error); - core.setFailed(error.message); + // Don't set failed in test mode (when GITHUB_OUTPUT is undefined) + if (process.env.GITHUB_OUTPUT !== undefined) { + core.setFailed(error.message); + } }); diff --git a/package-lock.json b/package-lock.json index d8d12f8c..d65d35d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "create-github-app-token", - "version": "2.0.6", + "version": "2.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "create-github-app-token", - "version": "2.0.6", + "version": "2.0.7", "license": "MIT", "dependencies": { "@actions/core": "^1.11.1", diff --git a/tests/main-enterprise-installation-not-found.test.js b/tests/main-enterprise-installation-not-found.test.js index c5a492d0..cc5be811 100644 --- a/tests/main-enterprise-installation-not-found.test.js +++ b/tests/main-enterprise-installation-not-found.test.js @@ -5,7 +5,7 @@ import { test } from "./main.js"; await test((mockPool) => { delete process.env.INPUT_OWNER; delete process.env.INPUT_REPOSITORIES; - process.env.INPUT_ENTERPRISE = "test-enterprise"; + process.env["INPUT_ENTERPRISE-SLUG"] = "test-enterprise"; // Mock the /app/installations endpoint to return only non-enterprise installations diff --git a/tests/main-enterprise-mutual-exclusivity-both.test.js b/tests/main-enterprise-mutual-exclusivity-both.test.js index f4b5e3bb..521f6e53 100644 --- a/tests/main-enterprise-mutual-exclusivity-both.test.js +++ b/tests/main-enterprise-mutual-exclusivity-both.test.js @@ -1,12 +1,12 @@ import { DEFAULT_ENV } from "./main.js"; -// Verify `main` exits with an error when `enterprise` is used with both `owner` and `repositories` inputs. +// Verify `main` exits with an error when `enterprise-slug` is used with both `owner` and `repositories` inputs. try { - // Set up environment with enterprise, owner, and repositories all set + // Set up environment with enterprise-slug, owner, and repositories all set for (const [key, value] of Object.entries(DEFAULT_ENV)) { process.env[key] = value; } - process.env.INPUT_ENTERPRISE = "test-enterprise"; + process.env["INPUT_ENTERPRISE-SLUG"] = "test-enterprise"; process.env.INPUT_OWNER = "test-owner"; process.env.INPUT_REPOSITORIES = "repo1,repo2"; diff --git a/tests/main-enterprise-mutual-exclusivity-owner.test.js b/tests/main-enterprise-mutual-exclusivity-owner.test.js index 59ec6373..eaa36da3 100644 --- a/tests/main-enterprise-mutual-exclusivity-owner.test.js +++ b/tests/main-enterprise-mutual-exclusivity-owner.test.js @@ -1,12 +1,12 @@ import { DEFAULT_ENV } from "./main.js"; -// Verify `main` exits with an error when `enterprise` is used with `owner` input. +// Verify `main` exits with an error when `enterprise-slug` is used with `owner` input. try { - // Set up environment with enterprise and owner both set + // Set up environment with enterprise-slug and owner set for (const [key, value] of Object.entries(DEFAULT_ENV)) { process.env[key] = value; } - process.env.INPUT_ENTERPRISE = "test-enterprise"; + process.env["INPUT_ENTERPRISE-SLUG"] = "test-enterprise"; process.env.INPUT_OWNER = "test-owner"; await import("../main.js"); diff --git a/tests/main-enterprise-mutual-exclusivity-repositories.test.js b/tests/main-enterprise-mutual-exclusivity-repositories.test.js index 893c7de0..c69f0f0b 100644 --- a/tests/main-enterprise-mutual-exclusivity-repositories.test.js +++ b/tests/main-enterprise-mutual-exclusivity-repositories.test.js @@ -1,12 +1,12 @@ import { DEFAULT_ENV } from "./main.js"; -// Verify `main` exits with an error when `enterprise` is used with `repositories` input. +// Verify `main` exits with an error when `enterprise-slug` is used with `repositories` input. try { - // Set up environment with enterprise and repositories both set + // Set up environment with enterprise-slug and repositories set for (const [key, value] of Object.entries(DEFAULT_ENV)) { process.env[key] = value; } - process.env.INPUT_ENTERPRISE = "test-enterprise"; + process.env["INPUT_ENTERPRISE-SLUG"] = "test-enterprise"; process.env.INPUT_REPOSITORIES = "repo1,repo2"; await import("../main.js"); diff --git a/tests/main-enterprise-only-success.test.js b/tests/main-enterprise-only-success.test.js index dae89bc9..68fcce72 100644 --- a/tests/main-enterprise-only-success.test.js +++ b/tests/main-enterprise-only-success.test.js @@ -1,8 +1,8 @@ import { test } from "./main.js"; -// Verify `main` successfully obtains a token when only the `enterprise` input is set. +// Verify `main` successfully obtains a token when only the `enterprise-slug` input is set. await test((mockPool) => { - process.env.INPUT_ENTERPRISE = "test-enterprise"; + process.env["INPUT_ENTERPRISE-SLUG"] = "test-enterprise"; delete process.env.INPUT_OWNER; delete process.env.INPUT_REPOSITORIES; @@ -26,7 +26,7 @@ await test((mockPool) => { id: mockInstallationId, app_slug: mockAppSlug, target_type: "Enterprise", - account: { login: "test-enterprise" } + account: { login: "test-enterprise", slug: "test-enterprise" } } ], { headers: { "content-type": "application/json" } } diff --git a/tests/main-enterprise-token-success.test.js b/tests/main-enterprise-token-success.test.js index 197348e5..69e2b5c2 100644 --- a/tests/main-enterprise-token-success.test.js +++ b/tests/main-enterprise-token-success.test.js @@ -2,7 +2,7 @@ import { test } from "./main.js"; // Verify `main` successfully generates enterprise token with basic functionality. await test((mockPool) => { - process.env.INPUT_ENTERPRISE = "test-enterprise"; + process.env["INPUT_ENTERPRISE-SLUG"] = "test-enterprise"; delete process.env.INPUT_OWNER; delete process.env.INPUT_REPOSITORIES; @@ -26,7 +26,7 @@ await test((mockPool) => { id: mockInstallationId, app_slug: mockAppSlug, target_type: "Enterprise", - account: { login: "test-enterprise" } + account: { login: "test-enterprise", slug: "test-enterprise" } } ], { headers: { "content-type": "application/json" } } diff --git a/tests/main-enterprise-token-with-permissions.test.js b/tests/main-enterprise-token-with-permissions.test.js index ab2a160c..3ffae88d 100644 --- a/tests/main-enterprise-token-with-permissions.test.js +++ b/tests/main-enterprise-token-with-permissions.test.js @@ -2,7 +2,7 @@ import { test } from "./main.js"; // Verify `main` successfully generates enterprise token with specific permissions. await test((mockPool) => { - process.env.INPUT_ENTERPRISE = "test-enterprise"; + process.env["INPUT_ENTERPRISE-SLUG"] = "test-enterprise"; delete process.env.INPUT_OWNER; delete process.env.INPUT_REPOSITORIES; process.env["INPUT_PERMISSION-ENTERPRISE-ORGANIZATIONS"] = "read"; @@ -28,7 +28,7 @@ await test((mockPool) => { id: mockInstallationId, app_slug: mockAppSlug, target_type: "Enterprise", - account: { login: "test-enterprise" } + account: { login: "test-enterprise", slug: "test-enterprise" } } ], { headers: { "content-type": "application/json" } } diff --git a/tests/snapshots/index.js.md b/tests/snapshots/index.js.md index dfd74737..b991609a 100644 --- a/tests/snapshots/index.js.md +++ b/tests/snapshots/index.js.md @@ -39,6 +39,70 @@ Generated by [AVA](https://avajs.dev). POST /api/v3/app/installations/123456/access_tokens␊ {"repositories":["create-github-app-token"]}` +## main-enterprise-installation-not-found.test.js + +> stderr + + `Error: No enterprise installation found matching the name test-enterprise. Available installations: Organization:some-org, User:some-user␊ + at getTokenFromEnterprise (file:///Users/s/dev/create-github-app-token/lib/main.js:229:11)␊ + at process.processTicksAndRejections (node:internal/process/task_queues:105:5)␊ + at async RetryOperation._fn (file:///Users/s/dev/create-github-app-token/node_modules/p-retry/index.js:55:20) {␊ + attemptNumber: 1,␊ + retriesLeft: 3␊ + }` + +> stdout + + `Creating enterprise installation token for enterprise "test-enterprise".␊ + Failed to create token for enterprise "test-enterprise" (attempt 1): No enterprise installation found matching the name test-enterprise. Available installations: Organization:some-org, User:some-user␊ + --- REQUESTS ---␊ + GET /app/installations` + +## main-enterprise-mutual-exclusivity-both.test.js + +> stderr + + `Error: Cannot use 'enterprise-slug' input with 'owner' or 'repositories' inputs␊ + at main (file:///Users/s/dev/create-github-app-token/lib/main.js:31:11)␊ + at file:///Users/s/dev/create-github-app-token/main.js:33:16␊ + at ModuleJob.run (node:internal/modules/esm/module_job:274:25)␊ + at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:644:26)␊ + at async file:///Users/s/dev/create-github-app-token/tests/main-enterprise-mutual-exclusivity-both.test.js:13:3` + +> stdout + + '' + +## main-enterprise-mutual-exclusivity-owner.test.js + +> stderr + + `Error: Cannot use 'enterprise-slug' input with 'owner' or 'repositories' inputs␊ + at main (file:///Users/s/dev/create-github-app-token/lib/main.js:31:11)␊ + at file:///Users/s/dev/create-github-app-token/main.js:33:16␊ + at ModuleJob.run (node:internal/modules/esm/module_job:274:25)␊ + at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:644:26)␊ + at async file:///Users/s/dev/create-github-app-token/tests/main-enterprise-mutual-exclusivity-owner.test.js:12:3` + +> stdout + + '' + +## main-enterprise-mutual-exclusivity-repositories.test.js + +> stderr + + `Error: Cannot use 'enterprise-slug' input with 'owner' or 'repositories' inputs␊ + at main (file:///Users/s/dev/create-github-app-token/lib/main.js:31:11)␊ + at file:///Users/s/dev/create-github-app-token/main.js:33:16␊ + at ModuleJob.run (node:internal/modules/esm/module_job:274:25)␊ + at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:644:26)␊ + at async file:///Users/s/dev/create-github-app-token/tests/main-enterprise-mutual-exclusivity-repositories.test.js:12:3` + +> stdout + + '' + ## main-enterprise-only-success.test.js > stderr @@ -48,14 +112,6 @@ Generated by [AVA](https://avajs.dev). > stdout `Creating enterprise installation token for enterprise "test-enterprise".␊ - ### Found enterprise installation: {␊ - "id": "123456",␊ - "app_slug": "github-actions",␊ - "target_type": "Enterprise",␊ - "account": {␊ - "login": "test-enterprise"␊ - }␊ - }␊ ::add-mask::ghs_16C7e42F292c6912E7710c838347Ae178B4a␊ ␊ ::set-output name=token::ghs_16C7e42F292c6912E7710c838347Ae178B4a␊ @@ -79,14 +135,6 @@ Generated by [AVA](https://avajs.dev). > stdout `Creating enterprise installation token for enterprise "test-enterprise".␊ - ### Found enterprise installation: {␊ - "id": "123456",␊ - "app_slug": "github-actions",␊ - "target_type": "Enterprise",␊ - "account": {␊ - "login": "test-enterprise"␊ - }␊ - }␊ ::add-mask::ghs_16C7e42F292c6912E7710c838347Ae178B4a␊ ␊ ::set-output name=token::ghs_16C7e42F292c6912E7710c838347Ae178B4a␊ @@ -110,14 +158,6 @@ Generated by [AVA](https://avajs.dev). > stdout `Creating enterprise installation token for enterprise "test-enterprise".␊ - ### Found enterprise installation: {␊ - "id": "123456",␊ - "app_slug": "github-actions",␊ - "target_type": "Enterprise",␊ - "account": {␊ - "login": "test-enterprise"␊ - }␊ - }␊ ::add-mask::ghs_16C7e42F292c6912E7710c838347Ae178B4a␊ ␊ ::set-output name=token::ghs_16C7e42F292c6912E7710c838347Ae178B4a␊ diff --git a/tests/snapshots/index.js.snap b/tests/snapshots/index.js.snap index edde1648817a03f4f23319d5c9aa3aedc6eb310c..87ac80eaa0ee9482e7a53f48a4b593fc9d34e0af 100644 GIT binary patch literal 2003 zcmV;^2Q2tORzV{rXc-R0Z$|5l>OhY#$;49#P=iltr`Ol}mPZat1g_W09^r!IR%4;hSkR}8zseBM(aRVN!kya}tl-XV^6%Xw; zRh_tLlZe`}2+ptf!RnXkCab;GeKQhN;(Z39gb4FdNkGIURa{VzB}4=u395pw3m}TJ zHd4F`47IpGsspISgMjz-|I__&5t&cX9!{ThJl!8AV~K1dsE9G~eLEzwi*dUn8}8Qi zZP=*YsO{9etsS?vx4rFFy=(Pr^^NUaaJR30xIv0Vj3p?W$0~{yVkCrjjkM)NP$m@# z0%H74eT>~&ePeTLR#R=i4B|G%qX8wu)J;hbY^jKX^BHzr0z8VS0J*C$u2tPFySi<= z?oq9V-3{zkKPlR_jSlzzc5m$en@do3{Zt@_t6^TK47X<)>IEX-)VOAO~b~c-})<($vH~{HHw$S1n%6xdFN3pqyYt?o1xTrNM z2qSen4x1n_a?3@8bZZLoBWNj%>cuCs;#%Qm#`V_<<6t$<^OGQ!YGFWtSXE~b~KWGNoORU3MKI@R4Yo6aI-ETN9wt&xC_ z4dDOEn-vkWY3hyG05TkYZ#?8pT-)BjwQ2fswjV}ZsEQKAgFAwU8WD{~B)H;@#v;)H zCq4+gwXuO~TT^1sGC=KFc~%@?w~p)Q+x}Efp~_%l0p|Xn!Q4+d=B_H{GC;s??YyAd zK83C{66RxXwUEKy&pGz4D)wgKVF?JnIjxejU|`GGY_*q2{0jfc0P_2b+aTrX+oh#% zQ+u;oL(Fu@F_vTO;xKkBmrpRJ*ETx>5mG6&)MYAJ?`ObPUCqIE z#liO2N+HL>kFAN<8a$N)OAA{9h;NnCEh6Ai02a3Tf-10{%&tNG(cso7VeJzVjQg3I zLt*FU{?V;_A2ts6?i}3RKRP&k&^Y+}?Y%=ZlvYMzEEQ^+YO1a$l-@D&-z%MOr|?F` z)bTcCFyG!=8GG?Ddr!2~c}J@`ufa)7hy?BF9ak3yNjL!MkqCU7K|i2u!KV5YF@a`c z!Y8M!ZtETS{0cXflfw1@t({OuUC-mkrNxgW3fa%3g55I!*D`F`h5mdZ{x<{DA682H zQpVsTqDsgM2o2F%i4s*>9}|%tW6H_`5${X~Rjha5-+;qX}i@@cCD0+>S00jgJ#1dlUc zR+5Tva_IAU404#ZY6C`HJV3|no50*tPcN)tqp72cR8-Kq$>`J}r^sbdBu>gvi8p4@V!`hhldRR}!riQ87l!$7 zGnoJ8w~F44YevC27f^;4v>HT1t#wnl!RM;iCkq{+#WljV0#mg=d84!txpr(G0$^QfCg-FZ>hJ_+}0F9 zGFGi6rVPcFk?hhan$U1!;>?5plwnInkNdA@jCy4P6nIDS?00000000B+n%!<2MHI)=AcV9C3GNUe7)^_ug2#@XkdIYCv{jnsf~F*? zNLy9bc=y;Iyq;NRX6>3NLPFvii5u>a+Be_{cmp1Q2SDNhxZ?uNtUq>B+ex!YTea(} z#GX0l%*_9HX3y+AYSaVawbieWK{Fk|Lqt8z1SdX(5zE3s%>b1Ey=FI29>vQkSK|t#eG?ey1%GnJ>q=#N{hcZsQNNLd_O{QB>ozRex zNCv0(dw2e)WRv;s{FWUFDu^BjSwNKgs30K}ifJJkr~;xAkOEyo*CkNRcoS*S296p+ zBHaSi5J4b%=KEwn*FpByO?0c#13h;BcbiFImL{*C0d56cE9KK2eeNl11~sr8NKZ{7A@S zUnn`iguoLmu!$fM9xgc-FJ449M8y4RmRxk;AcUEZU4-LV!$tEA4a1sIkNG~WpZIr8 zWfOEw_d_sEHpX7#?w%(iu5oN1AsmP%|zPj$Hioe5JVUNc5zQK4ft^K!`FM3FzbXjiLC~v(zL&sWaYy8=GNZLJJ)JE z8@IQ2xAwMo?$)-yxwWx_2G)wG6Df`Awv0eppu!bv|BJ%uDx;55p^hFp`_tpSnTpqa zBHm*oEnhKiP8)EXy+X;l=5e?U{Wx)f@@NP?;m`{hhfLXvrLxz(6P9&c=ozIQQ{#%R zj~0$8ATF2k{Bd6SBSQuEOiH(|6}YiMR2zEJLHsu*)4%5mTS`UXBdQC?3n=TLg#x3x zaCrnGXfs`=k#3oh zJV2XF-;C<$>g}!AgrP(Am4?pXe+kHEjRr{I8rAz~X%Q*#1ou@5?SPj3__X7@2$V*K zWgEb-iw9`1^@49uDorHzhtyPr&+;IqL0JI4U9X(kXTxk_%F~=aP1}r2C+to;f{Ayk z_~E)~=G=&pnAta-i3Al2t{}@!)lKQX8SDP2)R~&OH_;$SX&jk2eM7412(KML$+=+% zYxeLsl_h%PK_iS7sMgR4HL6^8lUc#|rd`ScS`Q#cNbjQ4)K|VBS6lg0li3)?E7be!UvyyL~b@Qw{E$fowM4vhD z=5OnAfj>uqOPaFqow81}iPHfDX=H!!<`wyO2 n9&@E&;ALVo? Date: Thu, 28 Aug 2025 10:26:30 +0200 Subject: [PATCH 12/37] bump version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d65d35d5..51831647 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "create-github-app-token", - "version": "2.0.7", + "version": "2.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "create-github-app-token", - "version": "2.0.7", + "version": "2.1.2", "license": "MIT", "dependencies": { "@actions/core": "^1.11.1", diff --git a/package.json b/package.json index 6a6de428..bacdd4ed 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "create-github-app-token", "private": true, "type": "module", - "version": "2.0.7", + "version": "2.1.2", "description": "GitHub Action for creating a GitHub App Installation Access Token", "scripts": { "build": "esbuild main.js post.js --bundle --outdir=dist --out-extension:.js=.cjs --platform=node --target=node20.0.0 --packages=bundle", From 2156e19368bb33dc9063fb24cf66983734841e5e Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:03:39 -0700 Subject: [PATCH 13/37] Remove dist changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dist/main.cjs | 111 +++++++++++++++----------------------------------- dist/post.cjs | 6 +-- 2 files changed, 36 insertions(+), 81 deletions(-) diff --git a/dist/main.cjs b/dist/main.cjs index 609d7a85..08aed01b 100644 --- a/dist/main.cjs +++ b/dist/main.cjs @@ -44436,10 +44436,10 @@ var OidcClient = class _OidcClient { var _a; const httpclient = _OidcClient.createHttpClient(); const res = yield httpclient.getJson(id_token_url).catch((error2) => { - throw new Error(`Failed to get ID Token. - + throw new Error(`Failed to get ID Token. + Error Code : ${error2.statusCode} - + Error Message: ${error2.message}`); }); const id_token = (_a = res.result) === null || _a === void 0 ? void 0 : _a.value; @@ -47919,46 +47919,39 @@ async function pRetry(input, options = {}) { } // lib/main.js -async function main(appId2, privateKey2, enterpriseSlug2, owner2, repositories2, permissions2, core, createAppAuth2, request2, skipTokenRevoke2) { - if (enterpriseSlug2 && (owner2 || repositories2.length > 0)) { - throw new Error("Cannot use 'enterprise-slug' input with 'owner' or 'repositories' inputs"); - } +async function main(appId2, privateKey2, owner2, repositories2, permissions2, core, createAppAuth2, request2, skipTokenRevoke2) { let parsedOwner = ""; let parsedRepositoryNames = []; - if (!enterpriseSlug2) { - if (!owner2 && repositories2.length === 0) { - const [owner3, repo] = String(process.env.GITHUB_REPOSITORY).split("/"); - parsedOwner = owner3; - parsedRepositoryNames = [repo]; - core.info( - `Inputs 'owner' and 'repositories' are not set. Creating token for this repository (${owner3}/${repo}).` - ); - } - if (owner2 && repositories2.length === 0) { - parsedOwner = owner2; - core.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; - core.info( - `No 'owner' input provided. Using default owner '${parsedOwner}' to create token for the following repositories:${repositories2.map((repo) => ` + if (!owner2 && repositories2.length === 0) { + const [owner3, repo] = String(process.env.GITHUB_REPOSITORY).split("/"); + parsedOwner = owner3; + parsedRepositoryNames = [repo]; + core.info( + `Inputs 'owner' and 'repositories' are not set. Creating token for this repository (${owner3}/${repo}).` + ); + } + if (owner2 && repositories2.length === 0) { + parsedOwner = owner2; + core.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; + core.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; - core.info( - `Inputs 'owner' and 'repositories' are set. Creating token for the following repositories: + ); + } + if (owner2 && repositories2.length > 0) { + parsedOwner = owner2; + parsedRepositoryNames = repositories2; + core.info( + `Inputs 'owner' and 'repositories' are set. Creating token for the following repositories: ${repositories2.map((repo) => ` - ${parsedOwner}/${repo}`).join("")}` - ); - } - } else { - core.info(`Creating enterprise installation token for enterprise "${enterpriseSlug2}".`); + ); } const auth5 = createAppAuth2({ appId: appId2, @@ -47966,20 +47959,7 @@ async function main(appId2, privateKey2, enterpriseSlug2, owner2, repositories2, request: request2 }); let authentication, installationId, appSlug; - if (enterpriseSlug2) { - ({ authentication, installationId, appSlug } = await pRetry( - () => getTokenFromEnterprise(request2, auth5, enterpriseSlug2, permissions2), - { - shouldRetry: (error2) => error2.status >= 500, - onFailedAttempt: (error2) => { - core.info( - `Failed to create token for enterprise "${enterpriseSlug2}" (attempt ${error2.attemptNumber}): ${error2.message}` - ); - }, - retries: 3 - } - )); - } else if (parsedRepositoryNames.length > 0) { + if (parsedRepositoryNames.length > 0) { ({ authentication, installationId, appSlug } = await pRetry( () => getTokenFromRepository( request2, @@ -48056,27 +48036,6 @@ async function getTokenFromRepository(request2, auth5, parsedOwner, parsedReposi const appSlug = response.data["app_slug"]; return { authentication, installationId, appSlug }; } -async function getTokenFromEnterprise(request2, auth5, enterpriseSlug2, 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 === enterpriseSlug2 - ); - if (!enterpriseInstallation) { - throw new Error(`No enterprise installation found matching the name ${enterpriseSlug2}. 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_undici2 = __toESM(require_undici2(), 1); @@ -48113,7 +48072,6 @@ if (!process.env.GITHUB_REPOSITORY_OWNER) { } var appId = getInput("app-id"); var privateKey = getInput("private-key"); -var enterpriseSlug = getInput("enterprise-slug"); var owner = getInput("owner"); var repositories = getInput("repositories").split(/[\n,]+/).map((s) => s.trim()).filter((x) => x !== ""); var skipTokenRevoke = getBooleanInput("skip-token-revoke"); @@ -48121,7 +48079,6 @@ var permissions = getPermissionsFromInputs(process.env); var main_default = main( appId, privateKey, - enterpriseSlug, owner, repositories, permissions, @@ -48131,9 +48088,7 @@ var main_default = main( skipTokenRevoke ).catch((error2) => { console.error(error2); - if (process.env.GITHUB_OUTPUT !== void 0) { - setFailed(error2.message); - } + setFailed(error2.message); }); /*! Bundled license information: diff --git a/dist/post.cjs b/dist/post.cjs index 7e39e7fc..be0457f7 100644 --- a/dist/post.cjs +++ b/dist/post.cjs @@ -44427,10 +44427,10 @@ var OidcClient = class _OidcClient { var _a; const httpclient = _OidcClient.createHttpClient(); const res = yield httpclient.getJson(id_token_url).catch((error2) => { - throw new Error(`Failed to get ID Token. - + throw new Error(`Failed to get ID Token. + Error Code : ${error2.statusCode} - + Error Message: ${error2.message}`); }); const id_token = (_a = res.result) === null || _a === void 0 ? void 0 : _a.value; From 4f9eeddfa73c23e60664e388323fa48ac177f136 Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:14:05 -0700 Subject: [PATCH 14/37] Use direct enterprise installation route Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- lib/main.js | 42 +++++++++---------- tests/index.js.snapshot | 16 +++---- ...-enterprise-installation-not-found.test.js | 25 +++-------- tests/main-enterprise-only-success.test.js | 16 +++---- tests/main-enterprise-token-success.test.js | 16 +++---- ...-enterprise-token-with-permissions.test.js | 16 +++---- 6 files changed, 51 insertions(+), 80 deletions(-) diff --git a/lib/main.js b/lib/main.js index 5a2c4c5f..85d276ec 100644 --- a/lib/main.js +++ b/lib/main.js @@ -208,37 +208,35 @@ async function getTokenFromRepository( return { authentication, installationId, appSlug }; } -async function getTokenFromEnterprise(request, auth, enterpriseSlug, 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, - }, - }); - - // Find the enterprise installation - const enterpriseInstallation = response.data.find( - installation => installation.target_type === "Enterprise" && - installation.account?.slug === enterpriseSlug - ); +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}.` + ); + } - /* c8 ignore next 3 */ - if (!enterpriseInstallation) { - throw new Error(`No enterprise installation found matching the name ${enterpriseSlug}. Available installations: ${response.data.map(i => `${i.target_type}:${i.account?.login || 'N/A'}`).join(', ')}`); + throw error; } // Get token for the enterprise installation const authentication = await auth({ type: "installation", - installationId: enterpriseInstallation.id, + installationId: response.data.id, permissions, }); - const installationId = enterpriseInstallation.id; - const appSlug = enterpriseInstallation["app_slug"]; + const installationId = response.data.id; + const appSlug = response.data["app_slug"]; return { authentication, installationId, appSlug }; } diff --git a/tests/index.js.snapshot b/tests/index.js.snapshot index c4552145..0681c494 100644 --- a/tests/index.js.snapshot +++ b/tests/index.js.snapshot @@ -18,20 +18,20 @@ POST /api/v3/app/installations/123456/access_tokens `; exports[`main-enterprise-installation-not-found.test.js > stderr 1`] = ` -Error: No enterprise installation found matching the name test-enterprise. Available installations: Organization:some-org, User:some-user - at getTokenFromEnterprise (file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/lib/main.js:230:11) +Error: No enterprise installation found matching the name test-enterprise. + at getTokenFromEnterprise (file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/lib/main.js:223:13)  at process.processTicksAndRejections (node:internal/process/task_queues:104:5) at async pRetry (file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/node_modules/p-retry/index.js:197:19) at async main (file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/lib/main.js:95:52) at async test (file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/tests/main.js:111:3) - at async file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/tests/main-enterprise-installation-not-found.test.js:5:1 + at async file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/tests/main-enterprise-installation-not-found.test.js:4:1 `; exports[`main-enterprise-installation-not-found.test.js > stdout 1`] = ` Creating enterprise installation token for enterprise "test-enterprise". -Failed to create token for enterprise "test-enterprise" (attempt 1): No enterprise installation found matching the name test-enterprise. Available installations: Organization:some-org, User:some-user +Failed to create token for enterprise "test-enterprise" (attempt 1): No enterprise installation found matching the name test-enterprise. --- REQUESTS --- -GET /app/installations +GET /enterprises/test-enterprise/installation `; exports[`main-enterprise-mutual-exclusivity-both.test.js > stderr 1`] = ` @@ -76,7 +76,7 @@ Creating enterprise installation token for enterprise "test-enterprise". ::save-state name=token::ghs_16C7e42F292c6912E7710c838347Ae178B4a ::save-state name=expiresAt::2016-07-11T22:14:10Z --- REQUESTS --- -GET /app/installations +GET /enterprises/test-enterprise/installation POST /app/installations/123456/access_tokens null `; @@ -93,7 +93,7 @@ Creating enterprise installation token for enterprise "test-enterprise". ::save-state name=token::ghs_16C7e42F292c6912E7710c838347Ae178B4a ::save-state name=expiresAt::2016-07-11T22:14:10Z --- REQUESTS --- -GET /app/installations +GET /enterprises/test-enterprise/installation POST /app/installations/123456/access_tokens null `; @@ -110,7 +110,7 @@ Creating enterprise installation token for enterprise "test-enterprise". ::save-state name=token::ghs_16C7e42F292c6912E7710c838347Ae178B4a ::save-state name=expiresAt::2016-07-11T22:14:10Z --- REQUESTS --- -GET /app/installations +GET /enterprises/test-enterprise/installation POST /app/installations/123456/access_tokens {"permissions":{"enterprise_organizations":"read","enterprise_people":"write"}} `; diff --git a/tests/main-enterprise-installation-not-found.test.js b/tests/main-enterprise-installation-not-found.test.js index cc5be811..0c36a26c 100644 --- a/tests/main-enterprise-installation-not-found.test.js +++ b/tests/main-enterprise-installation-not-found.test.js @@ -1,17 +1,15 @@ import { test } from "./main.js"; - // Verify `main` handles when no enterprise installation is found. await test((mockPool) => { delete process.env.INPUT_OWNER; - delete process.env.INPUT_REPOSITORIES; + delete process.env.INPUT_REPOSITORIES; process.env["INPUT_ENTERPRISE-SLUG"] = "test-enterprise"; - - // Mock the /app/installations endpoint to return only non-enterprise installations + // Mock the enterprise installation endpoint to return no matching installation mockPool .intercept({ - path: "/app/installations", + path: "/enterprises/test-enterprise/installation", method: "GET", headers: { accept: "application/vnd.github.v3+json", @@ -20,21 +18,8 @@ await test((mockPool) => { }, }) .reply( - 200, - [ - { - id: "111111", - app_slug: "github-actions", - target_type: "Organization", - account: { login: "some-org" } - }, - { - id: "222222", - app_slug: "github-actions", - target_type: "User", - account: { login: "some-user" } - } - ], + 404, + { message: "Not Found" }, { headers: { "content-type": "application/json" } } ); }); diff --git a/tests/main-enterprise-only-success.test.js b/tests/main-enterprise-only-success.test.js index 68fcce72..7f696efd 100644 --- a/tests/main-enterprise-only-success.test.js +++ b/tests/main-enterprise-only-success.test.js @@ -6,12 +6,12 @@ await test((mockPool) => { delete process.env.INPUT_OWNER; delete process.env.INPUT_REPOSITORIES; - // Mock the /app/installations endpoint to return an enterprise installation + // Mock the enterprise installation endpoint const mockInstallationId = "123456"; const mockAppSlug = "github-actions"; mockPool .intercept({ - path: "/app/installations", + path: "/enterprises/test-enterprise/installation", method: "GET", headers: { accept: "application/vnd.github.v3+json", @@ -21,14 +21,10 @@ await test((mockPool) => { }) .reply( 200, - [ - { - id: mockInstallationId, - app_slug: mockAppSlug, - target_type: "Enterprise", - account: { login: "test-enterprise", slug: "test-enterprise" } - } - ], + { + id: mockInstallationId, + app_slug: mockAppSlug, + }, { headers: { "content-type": "application/json" } } ); }); diff --git a/tests/main-enterprise-token-success.test.js b/tests/main-enterprise-token-success.test.js index 69e2b5c2..2128b7a9 100644 --- a/tests/main-enterprise-token-success.test.js +++ b/tests/main-enterprise-token-success.test.js @@ -6,12 +6,12 @@ await test((mockPool) => { delete process.env.INPUT_OWNER; delete process.env.INPUT_REPOSITORIES; - // Mock the /app/installations endpoint to return an enterprise installation + // Mock the enterprise installation endpoint const mockInstallationId = "123456"; const mockAppSlug = "github-actions"; mockPool .intercept({ - path: "/app/installations", + path: "/enterprises/test-enterprise/installation", method: "GET", headers: { accept: "application/vnd.github.v3+json", @@ -21,14 +21,10 @@ await test((mockPool) => { }) .reply( 200, - [ - { - id: mockInstallationId, - app_slug: mockAppSlug, - target_type: "Enterprise", - account: { login: "test-enterprise", slug: "test-enterprise" } - } - ], + { + id: mockInstallationId, + app_slug: mockAppSlug, + }, { headers: { "content-type": "application/json" } } ); }); diff --git a/tests/main-enterprise-token-with-permissions.test.js b/tests/main-enterprise-token-with-permissions.test.js index 3ffae88d..9f9c0cc7 100644 --- a/tests/main-enterprise-token-with-permissions.test.js +++ b/tests/main-enterprise-token-with-permissions.test.js @@ -8,12 +8,12 @@ await test((mockPool) => { process.env["INPUT_PERMISSION-ENTERPRISE-ORGANIZATIONS"] = "read"; process.env["INPUT_PERMISSION-ENTERPRISE-PEOPLE"] = "write"; - // Mock the /app/installations endpoint to return an enterprise installation + // Mock the enterprise installation endpoint const mockInstallationId = "123456"; const mockAppSlug = "github-actions"; mockPool .intercept({ - path: "/app/installations", + path: "/enterprises/test-enterprise/installation", method: "GET", headers: { accept: "application/vnd.github.v3+json", @@ -23,14 +23,10 @@ await test((mockPool) => { }) .reply( 200, - [ - { - id: mockInstallationId, - app_slug: mockAppSlug, - target_type: "Enterprise", - account: { login: "test-enterprise", slug: "test-enterprise" } - } - ], + { + id: mockInstallationId, + app_slug: mockAppSlug, + }, { headers: { "content-type": "application/json" } } ); }); From c7725c064da9079727bd9601d9cc58374bca05ab Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:24:24 -0700 Subject: [PATCH 15/37] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/main.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/main.js b/lib/main.js index 85d276ec..9489ceae 100644 --- a/lib/main.js +++ b/lib/main.js @@ -24,7 +24,6 @@ export async function main( createAppAuth, request, skipTokenRevoke, - ) { // Validate mutual exclusivity of enterprise-slug with owner/repositories if (enterpriseSlug && (owner || repositories.length > 0)) { From 7b114ed594d41cae8a13e50c38e40d0f8319527c Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:25:06 -0700 Subject: [PATCH 16/37] Add newline to .gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d5a9b6ae..87966f68 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ .env coverage node_modules/ -.DS_Store \ No newline at end of file +.DS_Store From f90c44a773bcfa7b9f391a86640d0926aa033ccf Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:29:21 -0700 Subject: [PATCH 17/37] Remove redundant enterprise tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/index.js.snapshot | 27 ----------------- ...enterprise-mutual-exclusivity-both.test.js | 16 ---------- tests/main-enterprise-token-success.test.js | 30 ------------------- 3 files changed, 73 deletions(-) delete mode 100644 tests/main-enterprise-mutual-exclusivity-both.test.js delete mode 100644 tests/main-enterprise-token-success.test.js diff --git a/tests/index.js.snapshot b/tests/index.js.snapshot index 0681c494..b5b4215e 100644 --- a/tests/index.js.snapshot +++ b/tests/index.js.snapshot @@ -34,16 +34,6 @@ Failed to create token for enterprise "test-enterprise" (attempt 1): No enterpri GET /enterprises/test-enterprise/installation `; -exports[`main-enterprise-mutual-exclusivity-both.test.js > stderr 1`] = ` -Error: Cannot use 'enterprise-slug' input with 'owner' or 'repositories' inputs - at main (file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/lib/main.js:31:11) - at run (file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/main.js:35:10) - at file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/main.js:50:16 - at ModuleJob.run (node:internal/modules/esm/module_job:430:25) - at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:639:26) - at async file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/tests/main-enterprise-mutual-exclusivity-both.test.js:13:3 -`; - exports[`main-enterprise-mutual-exclusivity-owner.test.js > stderr 1`] = ` Error: Cannot use 'enterprise-slug' input with 'owner' or 'repositories' inputs at main (file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/lib/main.js:31:11) @@ -81,23 +71,6 @@ POST /app/installations/123456/access_tokens null `; -exports[`main-enterprise-token-success.test.js > stdout 1`] = ` -Creating enterprise installation token for enterprise "test-enterprise". -::add-mask::ghs_16C7e42F292c6912E7710c838347Ae178B4a - -::set-output name=token::ghs_16C7e42F292c6912E7710c838347Ae178B4a - -::set-output name=installation-id::123456 - -::set-output name=app-slug::github-actions -::save-state name=token::ghs_16C7e42F292c6912E7710c838347Ae178B4a -::save-state name=expiresAt::2016-07-11T22:14:10Z ---- REQUESTS --- -GET /enterprises/test-enterprise/installation -POST /app/installations/123456/access_tokens -null -`; - exports[`main-enterprise-token-with-permissions.test.js > stdout 1`] = ` Creating enterprise installation token for enterprise "test-enterprise". ::add-mask::ghs_16C7e42F292c6912E7710c838347Ae178B4a diff --git a/tests/main-enterprise-mutual-exclusivity-both.test.js b/tests/main-enterprise-mutual-exclusivity-both.test.js deleted file mode 100644 index 521f6e53..00000000 --- a/tests/main-enterprise-mutual-exclusivity-both.test.js +++ /dev/null @@ -1,16 +0,0 @@ -import { DEFAULT_ENV } from "./main.js"; - -// Verify `main` exits with an error when `enterprise-slug` is used with both `owner` and `repositories` inputs. -try { - // Set up environment with enterprise-slug, owner, and repositories all set - for (const [key, value] of Object.entries(DEFAULT_ENV)) { - process.env[key] = value; - } - process.env["INPUT_ENTERPRISE-SLUG"] = "test-enterprise"; - process.env.INPUT_OWNER = "test-owner"; - process.env.INPUT_REPOSITORIES = "repo1,repo2"; - - await import("../main.js"); -} catch (error) { - console.error(error.message); -} diff --git a/tests/main-enterprise-token-success.test.js b/tests/main-enterprise-token-success.test.js deleted file mode 100644 index 2128b7a9..00000000 --- a/tests/main-enterprise-token-success.test.js +++ /dev/null @@ -1,30 +0,0 @@ -import { test } from "./main.js"; - -// Verify `main` successfully generates enterprise token with basic functionality. -await test((mockPool) => { - process.env["INPUT_ENTERPRISE-SLUG"] = "test-enterprise"; - delete process.env.INPUT_OWNER; - delete process.env.INPUT_REPOSITORIES; - - // Mock the enterprise installation endpoint - const mockInstallationId = "123456"; - const mockAppSlug = "github-actions"; - mockPool - .intercept({ - path: "/enterprises/test-enterprise/installation", - method: "GET", - headers: { - accept: "application/vnd.github.v3+json", - "user-agent": "actions/create-github-app-token", - // Intentionally omitting the `authorization` header, since JWT creation is not idempotent. - }, - }) - .reply( - 200, - { - id: mockInstallationId, - app_slug: mockAppSlug, - }, - { headers: { "content-type": "application/json" } } - ); -}); From 9175c03232588f01a18741f1a34dc1b3e9fdfd45 Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:35:48 -0700 Subject: [PATCH 18/37] Upgrade GitHub Action to v3 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index dff7ea81..b8f763ca 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ jobs: hello-world: runs-on: ubuntu-latest steps: - - uses: actions/create-github-app-token@v2 + - uses: actions/create-github-app-token@v3 id: app-token with: app-id: ${{ vars.APP_ID }} From 50b5a0836352a31022a44e32b18018cceb64eda7 Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Fri, 13 Mar 2026 19:05:44 -0700 Subject: [PATCH 19/37] Stabilize stderr snapshots Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/index.js | 9 ++++++++- tests/index.js.snapshot | 36 ++++++++++++++++++------------------ 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/tests/index.js b/tests/index.js index d3e25211..11ce4a85 100644 --- a/tests/index.js +++ b/tests/index.js @@ -11,6 +11,13 @@ snapshot.setDefaultSnapshotSerializers([ (value) => (typeof value === "string" ? value : undefined), ]); +function normalizeStderr(stderr) { + return stderr + .replaceAll(/\u001B\[[0-9;]*m/g, "") + .replaceAll(process.cwd(), "") + .replaceAll(/:\d+:\d+/g, "::"); +} + // Get all files in tests directory const files = readdirSync("tests"); @@ -42,7 +49,7 @@ for (const file of testFiles) { const { stderr, stdout } = await execFileAsync("node", [`tests/${file}`], { env, }); - const trimmedStderr = stderr.replace(/\r?\n$/, ""); + const trimmedStderr = normalizeStderr(stderr).replace(/\r?\n$/, ""); const trimmedStdout = stdout.replace(/\r?\n$/, ""); await t.test("stderr", (t) => { if (trimmedStderr) t.assert.snapshot(trimmedStderr); diff --git a/tests/index.js.snapshot b/tests/index.js.snapshot index b5b4215e..e9700a2b 100644 --- a/tests/index.js.snapshot +++ b/tests/index.js.snapshot @@ -19,12 +19,12 @@ POST /api/v3/app/installations/123456/access_tokens exports[`main-enterprise-installation-not-found.test.js > stderr 1`] = ` Error: No enterprise installation found matching the name test-enterprise. - at getTokenFromEnterprise (file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/lib/main.js:223:13) - at process.processTicksAndRejections (node:internal/process/task_queues:104:5) - at async pRetry (file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/node_modules/p-retry/index.js:197:19) - at async main (file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/lib/main.js:95:52) - at async test (file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/tests/main.js:111:3) - at async file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/tests/main-enterprise-installation-not-found.test.js:4:1 + at getTokenFromEnterprise (file:///lib/main.js::) + at process.processTicksAndRejections (node:internal/process/task_queues::) + at async pRetry (file:///node_modules/p-retry/index.js::) + at async main (file:///lib/main.js::) + at async test (file:///tests/main.js::) + at async file:///tests/main-enterprise-installation-not-found.test.js:: `; exports[`main-enterprise-installation-not-found.test.js > stdout 1`] = ` @@ -36,22 +36,22 @@ GET /enterprises/test-enterprise/installation exports[`main-enterprise-mutual-exclusivity-owner.test.js > stderr 1`] = ` Error: Cannot use 'enterprise-slug' input with 'owner' or 'repositories' inputs - at main (file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/lib/main.js:31:11) - at run (file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/main.js:35:10) - at file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/main.js:50:16 - at ModuleJob.run (node:internal/modules/esm/module_job:430:25) - at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:639:26) - at async file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/tests/main-enterprise-mutual-exclusivity-owner.test.js:12:3 + at main (file:///lib/main.js::) + at run (file:///main.js::) + at file:///main.js:: + at ModuleJob.run (node:internal/modules/esm/module_job::) + at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader::) + at async file:///tests/main-enterprise-mutual-exclusivity-owner.test.js:: `; exports[`main-enterprise-mutual-exclusivity-repositories.test.js > stderr 1`] = ` Error: Cannot use 'enterprise-slug' input with 'owner' or 'repositories' inputs - at main (file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/lib/main.js:31:11) - at run (file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/main.js:35:10) - at file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/main.js:50:16 - at ModuleJob.run (node:internal/modules/esm/module_job:430:25) - at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:639:26) - at async file:///Users/parkerbxyz/.copilot/worktrees/create-github-app-token/pr-263/tests/main-enterprise-mutual-exclusivity-repositories.test.js:12:3 + at main (file:///lib/main.js::) + at run (file:///main.js::) + at file:///main.js:: + at ModuleJob.run (node:internal/modules/esm/module_job::) + at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader::) + at async file:///tests/main-enterprise-mutual-exclusivity-repositories.test.js:: `; exports[`main-enterprise-only-success.test.js > stdout 1`] = ` From 17e8e94b156d3f973b146873c030c426e10441d0 Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:05:50 -0700 Subject: [PATCH 20/37] Build dist files for testing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dist/main.cjs | 110 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 80 insertions(+), 30 deletions(-) diff --git a/dist/main.cjs b/dist/main.cjs index e2674f23..bc432f69 100644 --- a/dist/main.cjs +++ b/dist/main.cjs @@ -23153,39 +23153,46 @@ 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, @@ -23193,7 +23200,20 @@ async function main(appId, privateKey, owner, repositories, permissions, core, c 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, @@ -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(/\/$/, ""); @@ -23309,6 +23355,7 @@ 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"); @@ -23316,6 +23363,7 @@ async function run() { return main( appId, privateKey, + enterpriseSlug, owner, repositories, permissions, @@ -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: From f942b7797ff742375eb19d86c71c07b61c92b6f8 Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:34:06 -0700 Subject: [PATCH 21/37] Rename enterprise input Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 6 ++-- action.yml | 2 +- dist/main.cjs | 26 ++++++++-------- lib/main.js | 30 +++++++++---------- main.js | 4 +-- tests/index.js.snapshot | 4 +-- ...-enterprise-installation-not-found.test.js | 2 +- ...nterprise-mutual-exclusivity-owner.test.js | 6 ++-- ...se-mutual-exclusivity-repositories.test.js | 6 ++-- tests/main-enterprise-only-success.test.js | 4 +-- ...-enterprise-token-with-permissions.test.js | 2 +- 11 files changed, 46 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index b8f763ca..0d887b42 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ jobs: with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.PRIVATE_KEY }} - enterprise-slug: my-enterprise-slug + enterprise: my-enterprise-slug - name: Call enterprise management REST API with gh run: | gh api /enterprises/my-enterprise-slug/apps/installable_organizations @@ -375,12 +375,12 @@ 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` +### `enterprise` **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. +> 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-` diff --git a/action.yml b/action.yml index 0c1e0449..50b69c61 100644 --- a/action.yml +++ b/action.yml @@ -17,7 +17,7 @@ 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: + enterprise: description: "Enterprise slug for enterprise-level app installations (cannot be used with 'owner' or 'repositories')" required: false skip-token-revoke: diff --git a/dist/main.cjs b/dist/main.cjs index bc432f69..de544291 100644 --- a/dist/main.cjs +++ b/dist/main.cjs @@ -23153,13 +23153,13 @@ async function pRetry(input, options = {}) { } // lib/main.js -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"); +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"); } let parsedOwner = ""; let parsedRepositoryNames = []; - if (!enterpriseSlug) { + if (!enterprise) { if (!owner && repositories.length === 0) { const [owner2, repo] = String(process.env.GITHUB_REPOSITORY).split("/"); parsedOwner = owner2; @@ -23192,7 +23192,7 @@ async function main(appId, privateKey, enterpriseSlug, owner, repositories, perm ); } } else { - core.info(`Creating enterprise installation token for enterprise "${enterpriseSlug}".`); + core.info(`Creating enterprise installation token for enterprise "${enterprise}".`); } const auth5 = createAppAuth2({ appId, @@ -23200,14 +23200,14 @@ async function main(appId, privateKey, enterpriseSlug, owner, repositories, perm request: request2 }); let authentication, installationId, appSlug; - if (enterpriseSlug) { + if (enterprise) { ({ authentication, installationId, appSlug } = await pRetry( - () => getTokenFromEnterprise(request2, auth5, enterpriseSlug, permissions), + () => getTokenFromEnterprise(request2, auth5, enterprise, permissions), { shouldRetry: ({ error: error2 }) => error2.status >= 500, onFailedAttempt: (context) => { core.info( - `Failed to create token for enterprise "${enterpriseSlug}" (attempt ${context.attemptNumber}): ${context.error.message}` + `Failed to create token for enterprise "${enterprise}" (attempt ${context.attemptNumber}): ${context.error.message}` ); }, retries: 3 @@ -23290,11 +23290,11 @@ async function getTokenFromRepository(request2, auth5, parsedOwner, parsedReposi const appSlug = response.data["app_slug"]; return { authentication, installationId, appSlug }; } -async function getTokenFromEnterprise(request2, auth5, enterpriseSlug, permissions) { +async function getTokenFromEnterprise(request2, auth5, enterprise, permissions) { let response; try { response = await request2("GET /enterprises/{enterprise}/installation", { - enterprise: enterpriseSlug, + enterprise, request: { hook: auth5.hook } @@ -23302,7 +23302,7 @@ async function getTokenFromEnterprise(request2, auth5, enterpriseSlug, permissio } catch (error2) { if (error2.status === 404) { throw new Error( - `No enterprise installation found matching the name ${enterpriseSlug}.` + `No enterprise installation found matching the name ${enterprise}.` ); } throw error2; @@ -23355,7 +23355,7 @@ async function run() { ensureNativeProxySupport(); const appId = getInput("app-id"); const privateKey = getInput("private-key"); - const enterpriseSlug = getInput("enterprise-slug"); + 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"); @@ -23363,7 +23363,7 @@ async function run() { return main( appId, privateKey, - enterpriseSlug, + enterprise, owner, repositories, permissions, diff --git a/lib/main.js b/lib/main.js index 9489ceae..582adf9c 100644 --- a/lib/main.js +++ b/lib/main.js @@ -4,7 +4,7 @@ import pRetry from "p-retry"; /** * @param {string} appId * @param {string} privateKey - * @param {string} enterpriseSlug + * @param {string} enterprise * @param {string} owner * @param {string[]} repositories * @param {undefined | Record} permissions @@ -16,7 +16,7 @@ import pRetry from "p-retry"; export async function main( appId, privateKey, - enterpriseSlug, + enterprise, owner, repositories, permissions, @@ -25,16 +25,16 @@ export async function main( request, skipTokenRevoke, ) { - // 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"); + // 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"); } let parsedOwner = ""; let parsedRepositoryNames = []; - // Skip owner/repository parsing if enterprise-slug is set - if (!enterpriseSlug) { + // 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("/"); @@ -78,7 +78,7 @@ export async function main( ); } } else { - core.info(`Creating enterprise installation token for enterprise "${enterpriseSlug}".`); + core.info(`Creating enterprise installation token for enterprise "${enterprise}".`); } const auth = createAppAuth({ @@ -89,15 +89,15 @@ export async function main( let authentication, installationId, appSlug; - // If enterprise-slug is set, get installation ID from the enterprise - if (enterpriseSlug) { + // If enterprise is set, get installation ID from the enterprise + if (enterprise) { ({ authentication, installationId, appSlug } = await pRetry( - () => getTokenFromEnterprise(request, auth, enterpriseSlug, permissions), + () => getTokenFromEnterprise(request, auth, enterprise, permissions), { shouldRetry: ({ error }) => error.status >= 500, onFailedAttempt: (context) => { core.info( - `Failed to create token for enterprise "${enterpriseSlug}" (attempt ${context.attemptNumber}): ${context.error.message}` + `Failed to create token for enterprise "${enterprise}" (attempt ${context.attemptNumber}): ${context.error.message}` ); }, retries: 3, @@ -207,11 +207,11 @@ async function getTokenFromRepository( return { authentication, installationId, appSlug }; } -async function getTokenFromEnterprise(request, auth, enterpriseSlug, permissions) { +async function getTokenFromEnterprise(request, auth, enterprise, permissions) { let response; try { response = await request("GET /enterprises/{enterprise}/installation", { - enterprise: enterpriseSlug, + enterprise, request: { hook: auth.hook, }, @@ -220,7 +220,7 @@ async function getTokenFromEnterprise(request, auth, enterpriseSlug, permissions /* c8 ignore next 8 */ if (error.status === 404) { throw new Error( - `No enterprise installation found matching the name ${enterpriseSlug}.` + `No enterprise installation found matching the name ${enterprise}.` ); } diff --git a/main.js b/main.js index 63662f09..ec65c025 100644 --- a/main.js +++ b/main.js @@ -20,7 +20,7 @@ async function run() { const appId = core.getInput("app-id"); const privateKey = core.getInput("private-key"); - const enterpriseSlug = core.getInput("enterprise-slug"); + const enterprise = core.getInput("enterprise"); const owner = core.getInput("owner"); const repositories = core .getInput("repositories") @@ -35,7 +35,7 @@ async function run() { return main( appId, privateKey, - enterpriseSlug, + enterprise, owner, repositories, permissions, diff --git a/tests/index.js.snapshot b/tests/index.js.snapshot index e9700a2b..ba868b66 100644 --- a/tests/index.js.snapshot +++ b/tests/index.js.snapshot @@ -35,7 +35,7 @@ GET /enterprises/test-enterprise/installation `; exports[`main-enterprise-mutual-exclusivity-owner.test.js > stderr 1`] = ` -Error: Cannot use 'enterprise-slug' input with 'owner' or 'repositories' inputs +Error: Cannot use 'enterprise' input with 'owner' or 'repositories' inputs at main (file:///lib/main.js::) at run (file:///main.js::) at file:///main.js:: @@ -45,7 +45,7 @@ Error: Cannot use 'enterprise-slug' input with 'owner' or 'repositories' inputs `; exports[`main-enterprise-mutual-exclusivity-repositories.test.js > stderr 1`] = ` -Error: Cannot use 'enterprise-slug' input with 'owner' or 'repositories' inputs +Error: Cannot use 'enterprise' input with 'owner' or 'repositories' inputs at main (file:///lib/main.js::) at run (file:///main.js::) at file:///main.js:: diff --git a/tests/main-enterprise-installation-not-found.test.js b/tests/main-enterprise-installation-not-found.test.js index 0c36a26c..a578967c 100644 --- a/tests/main-enterprise-installation-not-found.test.js +++ b/tests/main-enterprise-installation-not-found.test.js @@ -4,7 +4,7 @@ import { test } from "./main.js"; await test((mockPool) => { delete process.env.INPUT_OWNER; delete process.env.INPUT_REPOSITORIES; - process.env["INPUT_ENTERPRISE-SLUG"] = "test-enterprise"; + process.env.INPUT_ENTERPRISE = "test-enterprise"; // Mock the enterprise installation endpoint to return no matching installation mockPool diff --git a/tests/main-enterprise-mutual-exclusivity-owner.test.js b/tests/main-enterprise-mutual-exclusivity-owner.test.js index eaa36da3..f247169e 100644 --- a/tests/main-enterprise-mutual-exclusivity-owner.test.js +++ b/tests/main-enterprise-mutual-exclusivity-owner.test.js @@ -1,12 +1,12 @@ import { DEFAULT_ENV } from "./main.js"; -// Verify `main` exits with an error when `enterprise-slug` is used with `owner` input. +// Verify `main` exits with an error when `enterprise` is used with `owner` input. try { - // Set up environment with enterprise-slug and owner set + // Set up environment with enterprise and owner set for (const [key, value] of Object.entries(DEFAULT_ENV)) { process.env[key] = value; } - process.env["INPUT_ENTERPRISE-SLUG"] = "test-enterprise"; + process.env.INPUT_ENTERPRISE = "test-enterprise"; process.env.INPUT_OWNER = "test-owner"; await import("../main.js"); diff --git a/tests/main-enterprise-mutual-exclusivity-repositories.test.js b/tests/main-enterprise-mutual-exclusivity-repositories.test.js index c69f0f0b..f6e92b9e 100644 --- a/tests/main-enterprise-mutual-exclusivity-repositories.test.js +++ b/tests/main-enterprise-mutual-exclusivity-repositories.test.js @@ -1,12 +1,12 @@ import { DEFAULT_ENV } from "./main.js"; -// Verify `main` exits with an error when `enterprise-slug` is used with `repositories` input. +// Verify `main` exits with an error when `enterprise` is used with `repositories` input. try { - // Set up environment with enterprise-slug and repositories set + // Set up environment with enterprise and repositories set for (const [key, value] of Object.entries(DEFAULT_ENV)) { process.env[key] = value; } - process.env["INPUT_ENTERPRISE-SLUG"] = "test-enterprise"; + process.env.INPUT_ENTERPRISE = "test-enterprise"; process.env.INPUT_REPOSITORIES = "repo1,repo2"; await import("../main.js"); diff --git a/tests/main-enterprise-only-success.test.js b/tests/main-enterprise-only-success.test.js index 7f696efd..5008375e 100644 --- a/tests/main-enterprise-only-success.test.js +++ b/tests/main-enterprise-only-success.test.js @@ -1,8 +1,8 @@ import { test } from "./main.js"; -// Verify `main` successfully obtains a token when only the `enterprise-slug` input is set. +// Verify `main` successfully obtains a token when only the `enterprise` input is set. await test((mockPool) => { - process.env["INPUT_ENTERPRISE-SLUG"] = "test-enterprise"; + process.env.INPUT_ENTERPRISE = "test-enterprise"; delete process.env.INPUT_OWNER; delete process.env.INPUT_REPOSITORIES; diff --git a/tests/main-enterprise-token-with-permissions.test.js b/tests/main-enterprise-token-with-permissions.test.js index 9f9c0cc7..f1a7914c 100644 --- a/tests/main-enterprise-token-with-permissions.test.js +++ b/tests/main-enterprise-token-with-permissions.test.js @@ -2,7 +2,7 @@ import { test } from "./main.js"; // Verify `main` successfully generates enterprise token with specific permissions. await test((mockPool) => { - process.env["INPUT_ENTERPRISE-SLUG"] = "test-enterprise"; + process.env.INPUT_ENTERPRISE = "test-enterprise"; delete process.env.INPUT_OWNER; delete process.env.INPUT_REPOSITORIES; process.env["INPUT_PERMISSION-ENTERPRISE-ORGANIZATIONS"] = "read"; From c28e731c85b5a0312bda5c57f9d56b9b43cd05ac Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:39:53 -0700 Subject: [PATCH 22/37] Clarify enterprise input wording Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- action.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0d887b42..ed05ed23 100644 --- a/README.md +++ b/README.md @@ -377,7 +377,7 @@ steps: ### `enterprise` -**Optional:** The slug of the enterprise to generate a token for enterprise-level app installations. +**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. diff --git a/action.yml b/action.yml index 50b69c61..6231ee26 100644 --- a/action.yml +++ b/action.yml @@ -18,7 +18,7 @@ inputs: 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')" + description: "The slug version of the enterprise name 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" From 7b2a5fbdc3a467f3ecc9f3ad267d533fc9f7c94e Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:43:14 -0700 Subject: [PATCH 23/37] Restore failure semantics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dist/main.cjs | 4 +--- main.js | 7 ++----- tests/index.js | 15 ++++++++++++--- tests/index.js.snapshot | 13 +++++++++++++ 4 files changed, 28 insertions(+), 11 deletions(-) diff --git a/dist/main.cjs b/dist/main.cjs index de544291..a6cdabe1 100644 --- a/dist/main.cjs +++ b/dist/main.cjs @@ -23375,9 +23375,7 @@ async function run() { } var main_default = run().catch((error2) => { console.error(error2); - if (process.env.GITHUB_OUTPUT !== void 0) { - setFailed(error2.message); - } + setFailed(error2.message); }); /*! Bundled license information: diff --git a/main.js b/main.js index ec65c025..47af2fe8 100644 --- a/main.js +++ b/main.js @@ -48,10 +48,7 @@ async function run() { // Export promise for testing export default run().catch((error) => { - /* c8 ignore next 5 */ + /* c8 ignore next 3 */ console.error(error); - // Don't set failed in test mode (when GITHUB_OUTPUT is undefined) - if (process.env.GITHUB_OUTPUT !== undefined) { - core.setFailed(error.message); - } + core.setFailed(error.message); }); diff --git a/tests/index.js b/tests/index.js index 11ce4a85..74cf2723 100644 --- a/tests/index.js +++ b/tests/index.js @@ -46,9 +46,18 @@ for (const file of testFiles) { NODE_USE_ENV_PROXY, ...env } = process.env; - const { stderr, stdout } = await execFileAsync("node", [`tests/${file}`], { - env, - }); + let stderr, stdout; + try { + ({ stderr, stdout } = await execFileAsync("node", [`tests/${file}`], { + env, + })); + } catch (error) { + if (!(error instanceof Error) || !("stderr" in error) || !("stdout" in error)) { + throw error; + } + + ({ stderr, stdout } = error); + } const trimmedStderr = normalizeStderr(stderr).replace(/\r?\n$/, ""); const trimmedStdout = stdout.replace(/\r?\n$/, ""); await t.test("stderr", (t) => { diff --git a/tests/index.js.snapshot b/tests/index.js.snapshot index ba868b66..2b250a8d 100644 --- a/tests/index.js.snapshot +++ b/tests/index.js.snapshot @@ -30,6 +30,7 @@ Error: No enterprise installation found matching the name test-enterprise. exports[`main-enterprise-installation-not-found.test.js > stdout 1`] = ` Creating enterprise installation token for enterprise "test-enterprise". Failed to create token for enterprise "test-enterprise" (attempt 1): No enterprise installation found matching the name test-enterprise. +::error::No enterprise installation found matching the name test-enterprise. --- REQUESTS --- GET /enterprises/test-enterprise/installation `; @@ -44,6 +45,10 @@ Error: Cannot use 'enterprise' input with 'owner' or 'repositories' inputs at async file:///tests/main-enterprise-mutual-exclusivity-owner.test.js:: `; +exports[`main-enterprise-mutual-exclusivity-owner.test.js > stdout 1`] = ` +::error::Cannot use 'enterprise' input with 'owner' or 'repositories' inputs +`; + exports[`main-enterprise-mutual-exclusivity-repositories.test.js > stderr 1`] = ` Error: Cannot use 'enterprise' input with 'owner' or 'repositories' inputs at main (file:///lib/main.js::) @@ -54,6 +59,10 @@ Error: Cannot use 'enterprise' input with 'owner' or 'repositories' inputs at async file:///tests/main-enterprise-mutual-exclusivity-repositories.test.js:: `; +exports[`main-enterprise-mutual-exclusivity-repositories.test.js > stdout 1`] = ` +::error::Cannot use 'enterprise' input with 'owner' or 'repositories' inputs +`; + exports[`main-enterprise-only-success.test.js > stdout 1`] = ` Creating enterprise installation token for enterprise "test-enterprise". ::add-mask::ghs_16C7e42F292c6912E7710c838347Ae178B4a @@ -117,6 +126,10 @@ exports[`main-proxy-requires-native-support.test.js > stderr 1`] = ` A proxy environment variable is set, but Node.js native proxy support is not enabled. Set NODE_USE_ENV_PROXY=1 for this action step. `; +exports[`main-proxy-requires-native-support.test.js > stdout 1`] = ` +::error::A proxy environment variable is set, but Node.js native proxy support is not enabled. Set NODE_USE_ENV_PROXY=1 for this action step. +`; + exports[`main-repo-skew.test.js > stderr 1`] = ` 'Issued at' claim ('iat') must be an Integer representing the time that the assertion was issued. [@octokit/auth-app] GitHub API time and system time are different by 30 seconds. Retrying request with the difference accounted for. From a2a14fd8807bcfa5150efa45544556aa4fda1b67 Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:49:57 -0700 Subject: [PATCH 24/37] Simplify enterprise target flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dist/main.cjs | 97 ++++++++++++++++++++------------------- lib/main.js | 124 +++++++++++++++++++++++++------------------------- 2 files changed, 113 insertions(+), 108 deletions(-) diff --git a/dist/main.cjs b/dist/main.cjs index a6cdabe1..d7790586 100644 --- a/dist/main.cjs +++ b/dist/main.cjs @@ -23157,76 +23157,40 @@ async function main(appId, privateKey, enterprise, owner, repositories, permissi if (enterprise && (owner || repositories.length > 0)) { throw new Error("Cannot use 'enterprise' input with 'owner' or 'repositories' inputs"); } - let parsedOwner = ""; - let parsedRepositoryNames = []; - if (!enterprise) { - 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("")}` - ); - } - } else { - core.info(`Creating enterprise installation token for enterprise "${enterprise}".`); - } + const target = resolveInstallationTarget(enterprise, owner, repositories, core); const auth5 = createAppAuth2({ appId, privateKey, request: request2 }); let authentication, installationId, appSlug; - if (enterprise) { + if (target.type === "enterprise") { ({ authentication, installationId, appSlug } = await pRetry( - () => getTokenFromEnterprise(request2, auth5, enterprise, permissions), + () => getTokenFromEnterprise(request2, auth5, target.enterprise, permissions), { shouldRetry: ({ error: error2 }) => error2.status >= 500, onFailedAttempt: (context) => { core.info( - `Failed to create token for enterprise "${enterprise}" (attempt ${context.attemptNumber}): ${context.error.message}` + `Failed to create token for enterprise "${target.enterprise}" (attempt ${context.attemptNumber}): ${context.error.message}` ); }, retries: 3 } )); - } else if (parsedRepositoryNames.length > 0) { + } 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}` ); @@ -23236,11 +23200,11 @@ async function main(appId, privateKey, enterprise, owner, repositories, permissi )); } 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 @@ -23256,6 +23220,47 @@ async function main(appId, privateKey, enterprise, owner, repositories, permissi 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 getTokenFromOwner(request2, auth5, parsedOwner, permissions) { const response = await request2("GET /users/{username}/installation", { username: parsedOwner, diff --git a/lib/main.js b/lib/main.js index 582adf9c..f0d8db0d 100644 --- a/lib/main.js +++ b/lib/main.js @@ -30,56 +30,7 @@ export async function main( throw new Error("Cannot use 'enterprise' input with 'owner' or 'repositories' inputs"); } - let parsedOwner = ""; - let parsedRepositoryNames = []; - - // 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 target = resolveInstallationTarget(enterprise, owner, repositories, core); const auth = createAppAuth({ appId, @@ -88,36 +39,35 @@ export async function main( }); let authentication, installationId, appSlug; - - // If enterprise is set, get installation ID from the enterprise - if (enterprise) { + + if (target.type === "enterprise") { ({ authentication, installationId, appSlug } = await pRetry( - () => getTokenFromEnterprise(request, auth, enterprise, permissions), + () => getTokenFromEnterprise(request, auth, target.enterprise, permissions), { shouldRetry: ({ error }) => error.status >= 500, onFailedAttempt: (context) => { core.info( - `Failed to create token for enterprise "${enterprise}" (attempt ${context.attemptNumber}): ${context.error.message}` + `Failed to create token for enterprise "${target.enterprise}" (attempt ${context.attemptNumber}): ${context.error.message}` ); }, retries: 3, } )); - } else if (parsedRepositoryNames.length > 0) { + } else if (target.type === "repository") { ({ authentication, installationId, appSlug } = await pRetry( () => getTokenFromRepository( request, auth, - parsedOwner, - parsedRepositoryNames, + target.owner, + target.repositories, permissions ), { shouldRetry: ({ error }) => error.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}` ); @@ -128,11 +78,11 @@ export async function main( } else { // Otherwise get the installation for the owner, which can either be an organization or a user account ({ authentication, installationId, appSlug } = await pRetry( - () => getTokenFromOwner(request, auth, parsedOwner, permissions), + () => getTokenFromOwner(request, auth, 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, @@ -154,6 +104,56 @@ export async function main( } } +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, + }; +} + 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 @@ -164,7 +164,7 @@ async function getTokenFromOwner(request, auth, parsedOwner, permissions) { }, }); - // Get token for for all repositories of the given installation + // Get token for all repositories of the given installation const authentication = await auth({ type: "installation", installationId: response.data.id, From 8b90615b3f2bcab14a906ac53be02434c983d564 Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Fri, 20 Mar 2026 23:07:08 -0700 Subject: [PATCH 25/37] Extract installation auth helper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dist/main.cjs | 51 ++++++++++++++++++---------------------- lib/main.js | 64 +++++++++++++++++++++++---------------------------- 2 files changed, 51 insertions(+), 64 deletions(-) diff --git a/dist/main.cjs b/dist/main.cjs index d7790586..1c7250fd 100644 --- a/dist/main.cjs +++ b/dist/main.cjs @@ -23261,6 +23261,19 @@ function resolveInstallationTarget(enterprise, owner, repositories, core) { 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, @@ -23268,14 +23281,7 @@ async function getTokenFromOwner(request2, auth5, parsedOwner, permissions) { 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", { @@ -23285,15 +23291,9 @@ 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; @@ -23305,21 +23305,14 @@ async function getTokenFromEnterprise(request2, auth5, enterprise, permissions) } }); } catch (error2) { - if (error2.status === 404) { - throw new Error( - `No enterprise installation found matching the name ${enterprise}.` - ); + if (error2.status !== 404) { + throw error2; } - throw error2; + throw new Error( + `No enterprise installation found matching the name ${enterprise}.` + ); } - 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); } // lib/request.js diff --git a/lib/main.js b/lib/main.js index f0d8db0d..b05b761e 100644 --- a/lib/main.js +++ b/lib/main.js @@ -154,6 +154,26 @@ function resolveInstallationTarget(enterprise, owner, repositories, core) { }; } +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 @@ -165,16 +185,7 @@ async function getTokenFromOwner(request, auth, parsedOwner, permissions) { }); // Get token for all repositories of the given 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 }; + return createInstallationAuthResult(auth, response.data, permissions); } async function getTokenFromRepository( @@ -194,17 +205,9 @@ async function getTokenFromRepository( }); // Get token for given repositories - const authentication = await auth({ - type: "installation", - installationId: response.data.id, + return createInstallationAuthResult(auth, response.data, permissions, { repositoryNames: parsedRepositoryNames, - permissions, }); - - const installationId = response.data.id; - const appSlug = response.data["app_slug"]; - - return { authentication, installationId, appSlug }; } async function getTokenFromEnterprise(request, auth, enterprise, permissions) { @@ -217,25 +220,16 @@ async function getTokenFromEnterprise(request, auth, enterprise, permissions) { }, }); } catch (error) { - /* c8 ignore next 8 */ - if (error.status === 404) { - throw new Error( - `No enterprise installation found matching the name ${enterprise}.` - ); + /* c8 ignore next 3 */ + if (error.status !== 404) { + throw error; } - throw error; + throw new Error( + `No enterprise installation found matching the name ${enterprise}.` + ); } // 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 }; + return createInstallationAuthResult(auth, response.data, permissions); } From de40320cff7788de1863ad0a2ca7ff0b4d7462f9 Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Fri, 20 Mar 2026 23:11:27 -0700 Subject: [PATCH 26/37] Test enterprise retry path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dist/main.cjs | 10 +++--- lib/main.js | 11 +++--- tests/index.js.snapshot | 19 ++++++++++ tests/main-enterprise-fail-response.test.js | 39 +++++++++++++++++++++ 4 files changed, 68 insertions(+), 11 deletions(-) create mode 100644 tests/main-enterprise-fail-response.test.js diff --git a/dist/main.cjs b/dist/main.cjs index 1c7250fd..463e9adb 100644 --- a/dist/main.cjs +++ b/dist/main.cjs @@ -23305,12 +23305,12 @@ async function getTokenFromEnterprise(request2, auth5, enterprise, permissions) } }); } catch (error2) { - if (error2.status !== 404) { - throw error2; + if (error2.status === 404) { + throw new Error( + `No enterprise installation found matching the name ${enterprise}.` + ); } - throw new Error( - `No enterprise installation found matching the name ${enterprise}.` - ); + throw error2; } return createInstallationAuthResult(auth5, response.data, permissions); } diff --git a/lib/main.js b/lib/main.js index b05b761e..c9fc320f 100644 --- a/lib/main.js +++ b/lib/main.js @@ -220,14 +220,13 @@ async function getTokenFromEnterprise(request, auth, enterprise, permissions) { }, }); } catch (error) { - /* c8 ignore next 3 */ - if (error.status !== 404) { - throw error; + if (error.status === 404) { + throw new Error( + `No enterprise installation found matching the name ${enterprise}.` + ); } - throw new Error( - `No enterprise installation found matching the name ${enterprise}.` - ); + throw error; } // Get token for the enterprise installation diff --git a/tests/index.js.snapshot b/tests/index.js.snapshot index 2b250a8d..be443b91 100644 --- a/tests/index.js.snapshot +++ b/tests/index.js.snapshot @@ -17,6 +17,25 @@ POST /api/v3/app/installations/123456/access_tokens {"repositories":["create-github-app-token"]} `; +exports[`main-enterprise-fail-response.test.js > stdout 1`] = ` +Creating enterprise installation token for enterprise "test-enterprise". +Failed to create token for enterprise "test-enterprise" (attempt 1): GitHub API not available +::add-mask::ghs_16C7e42F292c6912E7710c838347Ae178B4a + +::set-output name=token::ghs_16C7e42F292c6912E7710c838347Ae178B4a + +::set-output name=installation-id::123456 + +::set-output name=app-slug::github-actions +::save-state name=token::ghs_16C7e42F292c6912E7710c838347Ae178B4a +::save-state name=expiresAt::2016-07-11T22:14:10Z +--- REQUESTS --- +GET /enterprises/test-enterprise/installation +GET /enterprises/test-enterprise/installation +POST /app/installations/123456/access_tokens +null +`; + exports[`main-enterprise-installation-not-found.test.js > stderr 1`] = ` Error: No enterprise installation found matching the name test-enterprise. at getTokenFromEnterprise (file:///lib/main.js::) diff --git a/tests/main-enterprise-fail-response.test.js b/tests/main-enterprise-fail-response.test.js new file mode 100644 index 00000000..068e2cba --- /dev/null +++ b/tests/main-enterprise-fail-response.test.js @@ -0,0 +1,39 @@ +import { test } from "./main.js"; + +// Verify enterprise installation lookup retries when the GitHub API returns a 500 error. +await test((mockPool) => { + process.env.INPUT_ENTERPRISE = "test-enterprise"; + delete process.env.INPUT_OWNER; + delete process.env.INPUT_REPOSITORIES; + + const mockInstallationId = "123456"; + const mockAppSlug = "github-actions"; + + mockPool + .intercept({ + path: "/enterprises/test-enterprise/installation", + method: "GET", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": "actions/create-github-app-token", + // Intentionally omitting the `authorization` header, since JWT creation is not idempotent. + }, + }) + .reply(500, "GitHub API not available"); + + mockPool + .intercept({ + path: "/enterprises/test-enterprise/installation", + method: "GET", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": "actions/create-github-app-token", + // Intentionally omitting the `authorization` header, since JWT creation is not idempotent. + }, + }) + .reply( + 200, + { id: mockInstallationId, app_slug: mockAppSlug }, + { headers: { "content-type": "application/json" } }, + ); +}); From 806ce0a6324a03e9888fc8d36dc7075efb33d770 Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:12:17 -0700 Subject: [PATCH 27/37] test: simplify enterprise exclusivity tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...nterprise-mutual-exclusivity-owner.test.js | 19 ++++++++----------- ...se-mutual-exclusivity-repositories.test.js | 19 ++++++++----------- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/tests/main-enterprise-mutual-exclusivity-owner.test.js b/tests/main-enterprise-mutual-exclusivity-owner.test.js index f247169e..4d0f00b8 100644 --- a/tests/main-enterprise-mutual-exclusivity-owner.test.js +++ b/tests/main-enterprise-mutual-exclusivity-owner.test.js @@ -1,15 +1,12 @@ import { DEFAULT_ENV } from "./main.js"; // Verify `main` exits with an error when `enterprise` is used with `owner` input. -try { - // Set up environment with enterprise and owner set - for (const [key, value] of Object.entries(DEFAULT_ENV)) { - process.env[key] = value; - } - process.env.INPUT_ENTERPRISE = "test-enterprise"; - process.env.INPUT_OWNER = "test-owner"; - - await import("../main.js"); -} catch (error) { - console.error(error.message); +// Set up environment with enterprise and owner set +for (const [key, value] of Object.entries(DEFAULT_ENV)) { + process.env[key] = value; } + +process.env.INPUT_ENTERPRISE = "test-enterprise"; +process.env.INPUT_OWNER = "test-owner"; + +await import("../main.js"); diff --git a/tests/main-enterprise-mutual-exclusivity-repositories.test.js b/tests/main-enterprise-mutual-exclusivity-repositories.test.js index f6e92b9e..00c70b72 100644 --- a/tests/main-enterprise-mutual-exclusivity-repositories.test.js +++ b/tests/main-enterprise-mutual-exclusivity-repositories.test.js @@ -1,15 +1,12 @@ import { DEFAULT_ENV } from "./main.js"; // Verify `main` exits with an error when `enterprise` is used with `repositories` input. -try { - // Set up environment with enterprise and repositories set - for (const [key, value] of Object.entries(DEFAULT_ENV)) { - process.env[key] = value; - } - process.env.INPUT_ENTERPRISE = "test-enterprise"; - process.env.INPUT_REPOSITORIES = "repo1,repo2"; - - await import("../main.js"); -} catch (error) { - console.error(error.message); +// Set up environment with enterprise and repositories set +for (const [key, value] of Object.entries(DEFAULT_ENV)) { + process.env[key] = value; } + +process.env.INPUT_ENTERPRISE = "test-enterprise"; +process.env.INPUT_REPOSITORIES = "repo1,repo2"; + +await import("../main.js"); From 22a239ffcf3a893e905266d6cf0a3a53b4ad0107 Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:23:36 -0700 Subject: [PATCH 28/37] docs: use client-id in enterprise example Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9fc37579..efcd8539 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ jobs: - uses: actions/create-github-app-token@v3 id: app-token with: - app-id: ${{ vars.APP_ID }} + client-id: ${{ vars.APP_CLIENT_ID }} private-key: ${{ secrets.PRIVATE_KEY }} enterprise: my-enterprise-slug - name: Call enterprise management REST API with gh From 92600600d15d72f15b3a6128e8c93686fa7007df Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:30:56 -0700 Subject: [PATCH 29/37] fix: align owner token retry behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dist/main.cjs | 42 ++++++------------ lib/main.js | 43 +++++++------------ tests/index.js.snapshot | 39 +++++++++++++++++ ...n-token-get-owner-set-client-error.test.js | 23 ++++++++++ 4 files changed, 91 insertions(+), 56 deletions(-) create mode 100644 tests/main-token-get-owner-set-client-error.test.js diff --git a/dist/main.cjs b/dist/main.cjs index cc2a15cc..fddd08e8 100644 --- a/dist/main.cjs +++ b/dist/main.cjs @@ -23167,15 +23167,7 @@ async function main(clientId, privateKey, enterprise, owner, repositories, permi 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 - } + createTokenRetryOptions(core, `enterprise "${target.enterprise}"`) )); } else if (target.type === "repository") { ({ authentication, installationId, appSlug } = await pRetry( @@ -23186,29 +23178,12 @@ async function main(clientId, privateKey, enterprise, owner, repositories, permi target.repositories, permissions ), - { - shouldRetry: ({ error: error2 }) => error2.status >= 500, - onFailedAttempt: (context) => { - core.info( - `Failed to create token for "${target.repositories.join( - "," - )}" (attempt ${context.attemptNumber}): ${context.error.message}` - ); - }, - retries: 3 - } + createTokenRetryOptions(core, `"${target.repositories.join(",")}"`) )); } else { ({ authentication, installationId, appSlug } = await pRetry( () => getTokenFromOwner(request2, auth5, target.owner, permissions), - { - onFailedAttempt: (context) => { - core.info( - `Failed to create token for "${target.owner}" (attempt ${context.attemptNumber}): ${context.error.message}` - ); - }, - retries: 3 - } + createTokenRetryOptions(core, `"${target.owner}"`) )); } core.setSecret(authentication.token); @@ -23261,6 +23236,17 @@ function resolveInstallationTarget(enterprise, owner, repositories, core) { repositories }; } +function createTokenRetryOptions(core, targetDescription) { + return { + shouldRetry: ({ error: error2 }) => error2.status >= 500, + onFailedAttempt: (context) => { + core.info( + `Failed to create token for ${targetDescription} (attempt ${context.attemptNumber}): ${context.error.message}` + ); + }, + retries: 3 + }; +} async function createInstallationAuthResult(auth5, installation, permissions, options = {}) { const authentication = await auth5({ type: "installation", diff --git a/lib/main.js b/lib/main.js index d84ac240..04c3f978 100644 --- a/lib/main.js +++ b/lib/main.js @@ -43,15 +43,7 @@ export async function main( if (target.type === "enterprise") { ({ authentication, installationId, appSlug } = await pRetry( () => getTokenFromEnterprise(request, auth, target.enterprise, permissions), - { - shouldRetry: ({ error }) => error.status >= 500, - onFailedAttempt: (context) => { - core.info( - `Failed to create token for enterprise "${target.enterprise}" (attempt ${context.attemptNumber}): ${context.error.message}` - ); - }, - retries: 3, - } + createTokenRetryOptions(core, `enterprise "${target.enterprise}"`) )); } else if (target.type === "repository") { ({ authentication, installationId, appSlug } = await pRetry( @@ -63,30 +55,13 @@ export async function main( target.repositories, permissions ), - { - shouldRetry: ({ error }) => error.status >= 500, - onFailedAttempt: (context) => { - core.info( - `Failed to create token for "${target.repositories.join( - "," - )}" (attempt ${context.attemptNumber}): ${context.error.message}` - ); - }, - retries: 3, - } + createTokenRetryOptions(core, `"${target.repositories.join(",")}"`) )); } else { // Otherwise get the installation for the owner, which can either be an organization or a user account ({ authentication, installationId, appSlug } = await pRetry( () => getTokenFromOwner(request, auth, target.owner, permissions), - { - onFailedAttempt: (context) => { - core.info( - `Failed to create token for "${target.owner}" (attempt ${context.attemptNumber}): ${context.error.message}` - ); - }, - retries: 3, - } + createTokenRetryOptions(core, `"${target.owner}"`) )); } @@ -154,6 +129,18 @@ function resolveInstallationTarget(enterprise, owner, repositories, core) { }; } +function createTokenRetryOptions(core, targetDescription) { + return { + shouldRetry: ({ error }) => error.status >= 500, + onFailedAttempt: (context) => { + core.info( + `Failed to create token for ${targetDescription} (attempt ${context.attemptNumber}): ${context.error.message}` + ); + }, + retries: 3, + }; +} + async function createInstallationAuthResult( auth, installation, diff --git a/tests/index.js.snapshot b/tests/index.js.snapshot index 5b5cf671..a8954b2d 100644 --- a/tests/index.js.snapshot +++ b/tests/index.js.snapshot @@ -220,6 +220,45 @@ POST /app/installations/123456/access_tokens {"repositories":["failed-repo"]} `; +exports[`main-token-get-owner-set-client-error.test.js > stderr 1`] = ` +RequestError [HttpError]: Forbidden + at fetchWrapper (file:///node_modules/@octokit/request/dist-bundle/index.js::) + at process.processTicksAndRejections (node:internal/process/task_queues::) + at async hook (file:///node_modules/@octokit/auth-app/dist-node/index.js::) + at async getTokenFromOwner (file:///lib/main.js::) + at async pRetry (file:///node_modules/p-retry/index.js::) + at async main (file:///lib/main.js::) + at async test (file:///tests/main.js::) + at async file:///tests/main-token-get-owner-set-client-error.test.js:: { + status: 403, + request: { + method: 'GET', + url: 'https://api.github.com/users/smockle/installation', + headers: { + accept: 'application/vnd.github.v3+json', + 'user-agent': 'actions/create-github-app-token', + authorization: 'bearer [REDACTED]' + }, + request: { hook: [Function: bound hook] AsyncFunction } + }, + response: { + url: 'https://api.github.com/users/smockle/installation', + status: 403, + headers: { 'content-type': 'application/json' }, + data: { message: 'Forbidden' } + }, + [cause]: undefined +} +`; + +exports[`main-token-get-owner-set-client-error.test.js > stdout 1`] = ` +Input 'repositories' is not set. Creating token for all repositories owned by smockle. +Failed to create token for "smockle" (attempt 1): Forbidden +::error::Forbidden +--- REQUESTS --- +GET /users/smockle/installation +`; + exports[`main-token-get-owner-set-fail-response.test.js > stdout 1`] = ` Input 'repositories' is not set. Creating token for all repositories owned by smockle. Failed to create token for "smockle" (attempt 1): GitHub API not available diff --git a/tests/main-token-get-owner-set-client-error.test.js b/tests/main-token-get-owner-set-client-error.test.js new file mode 100644 index 00000000..237b405f --- /dev/null +++ b/tests/main-token-get-owner-set-client-error.test.js @@ -0,0 +1,23 @@ +import { test } from "./main.js"; + +// Verify client errors are not retried when getting a token for a user or organization. +await test((mockPool) => { + process.env.INPUT_OWNER = "smockle"; + delete process.env.INPUT_REPOSITORIES; + + mockPool + .intercept({ + path: "/users/smockle/installation", + method: "GET", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": "actions/create-github-app-token", + // Intentionally omitting the `authorization` header, since JWT creation is not idempotent. + }, + }) + .reply( + 403, + { message: "Forbidden" }, + { headers: { "content-type": "application/json" } }, + ); +}); From b4b15fc8ddc899c366c332fd63648425216c7e6f Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Thu, 7 May 2026 23:58:54 -0700 Subject: [PATCH 30/37] build: rebuild main bundle from lockfile Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dist/main.cjs | 111 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 77 insertions(+), 34 deletions(-) diff --git a/dist/main.cjs b/dist/main.cjs index fddd08e8..07854ee3 100644 --- a/dist/main.cjs +++ b/dist/main.cjs @@ -22964,30 +22964,37 @@ var isError = (value) => objectToString.call(value) === "[object Error]"; var errorMessages = /* @__PURE__ */ new Set([ "network error", // Chrome - "Failed to fetch", - // Chrome "NetworkError when attempting to fetch resource.", // Firefox "The Internet connection appears to be offline.", // Safari 16 - "Load failed", - // Safari 17+ "Network request failed", // `cross-fetch` "fetch failed", // Undici (Node.js) - "terminated" + "terminated", // Undici (Node.js) + " A network error occurred.", + // Bun (WebKit) + "Network connection lost" + // Cloudflare Workers (fetch) ]); function isNetworkError(error2) { const isValid = error2 && isError(error2) && error2.name === "TypeError" && typeof error2.message === "string"; if (!isValid) { return false; } - if (error2.message === "Load failed") { - return error2.stack === void 0; + const { message, stack } = error2; + if (message === "Load failed") { + return stack === void 0 || "__sentry_captured__" in error2; + } + if (message.startsWith("error sending request for url")) { + return true; + } + if (message === "Failed to fetch" || message.startsWith("Failed to fetch (") && message.endsWith(")")) { + return true; } - return errorMessages.has(error2.message); + return errorMessages.has(message); } // node_modules/p-retry/index.js @@ -23017,6 +23024,14 @@ function validateNumberOption(name, value, { min = 0, allowInfinity = false } = throw new TypeError(`Expected \`${name}\` to be \u2265 ${min}.`); } } +function validateFunctionOption(name, value) { + if (value === void 0) { + return; + } + if (typeof value !== "function") { + throw new TypeError(`Expected \`${name}\` to be a function.`); + } +} var AbortError = class extends Error { constructor(message) { super(); @@ -23044,6 +23059,26 @@ function calculateRemainingTime(start, max) { } return max - (performance.now() - start); } +async function delayForRetry(delay, options) { + if (delay <= 0) { + return; + } + await new Promise((resolve2, reject) => { + const onAbort = () => { + clearTimeout(timeoutToken); + options.signal?.removeEventListener("abort", onAbort); + reject(options.signal.reason); + }; + const timeoutToken = setTimeout(() => { + options.signal?.removeEventListener("abort", onAbort); + resolve2(); + }, delay); + if (options.unref) { + timeoutToken.unref?.(); + } + options.signal?.addEventListener("abort", onAbort, { once: true }); + }); +} async function onAttemptFailure({ error: error2, attemptNumber, retriesConsumed, startTime, options }) { const normalizedError = error2 instanceof Error ? error2 : new TypeError(`Non-error was thrown: "${error2}". You should only throw errors.`); if (normalizedError instanceof AbortError) { @@ -23051,55 +23086,60 @@ async function onAttemptFailure({ error: error2, attemptNumber, retriesConsumed, } const retriesLeft = Number.isFinite(options.retries) ? Math.max(0, options.retries - retriesConsumed) : options.retries; const maxRetryTime = options.maxRetryTime ?? Number.POSITIVE_INFINITY; + const delayTime = calculateDelay(retriesConsumed, options); + const remainingTimeBeforeCallbacks = calculateRemainingTime(startTime, maxRetryTime); + if (remainingTimeBeforeCallbacks <= 0) { + const context2 = Object.freeze({ + error: normalizedError, + attemptNumber, + retriesLeft, + retriesConsumed, + retryDelay: 0 + }); + await options.onFailedAttempt(context2); + throw normalizedError; + } + const consumeRetryContext = Object.freeze({ + error: normalizedError, + attemptNumber, + retriesLeft, + retriesConsumed, + retryDelay: retriesLeft > 0 ? delayTime : 0 + }); + const consumeRetry = await options.shouldConsumeRetry(consumeRetryContext); + const effectiveDelay = consumeRetry && retriesLeft > 0 ? delayTime : 0; const context = Object.freeze({ error: normalizedError, attemptNumber, retriesLeft, - retriesConsumed + retriesConsumed, + retryDelay: effectiveDelay }); await options.onFailedAttempt(context); if (calculateRemainingTime(startTime, maxRetryTime) <= 0) { throw normalizedError; } - const consumeRetry = await options.shouldConsumeRetry(context); const remainingTime = calculateRemainingTime(startTime, maxRetryTime); if (remainingTime <= 0 || retriesLeft <= 0) { throw normalizedError; } if (normalizedError instanceof TypeError && !isNetworkError(normalizedError)) { - if (consumeRetry) { - throw normalizedError; - } - options.signal?.throwIfAborted(); - return false; + throw normalizedError; } if (!await options.shouldRetry(context)) { throw normalizedError; } + const remainingTimeAfterShouldRetry = calculateRemainingTime(startTime, maxRetryTime); + if (remainingTimeAfterShouldRetry <= 0) { + throw normalizedError; + } if (!consumeRetry) { options.signal?.throwIfAborted(); return false; } - const delayTime = calculateDelay(retriesConsumed, options); - const finalDelay = Math.min(delayTime, remainingTime); + const finalDelay = Math.min(effectiveDelay, remainingTimeAfterShouldRetry); options.signal?.throwIfAborted(); - if (finalDelay > 0) { - await new Promise((resolve2, reject) => { - const onAbort = () => { - clearTimeout(timeoutToken); - options.signal?.removeEventListener("abort", onAbort); - reject(options.signal.reason); - }; - const timeoutToken = setTimeout(() => { - options.signal?.removeEventListener("abort", onAbort); - resolve2(); - }, finalDelay); - if (options.unref) { - timeoutToken.unref?.(); - } - options.signal?.addEventListener("abort", onAbort, { once: true }); - }); - } + await delayForRetry(finalDelay, options); options.signal?.throwIfAborted(); return true; } @@ -23119,6 +23159,9 @@ async function pRetry(input, options = {}) { }; options.shouldRetry ??= () => true; options.shouldConsumeRetry ??= () => true; + validateFunctionOption("onFailedAttempt", options.onFailedAttempt); + validateFunctionOption("shouldRetry", options.shouldRetry); + validateFunctionOption("shouldConsumeRetry", options.shouldConsumeRetry); validateNumberOption("factor", options.factor, { min: 0, allowInfinity: false }); validateNumberOption("minTimeout", options.minTimeout, { min: 0, allowInfinity: false }); validateNumberOption("maxTimeout", options.maxTimeout, { min: 0, allowInfinity: true }); From d0ac922b7ce3a4428a80f2674e288d35885030d9 Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Fri, 8 May 2026 00:10:20 -0700 Subject: [PATCH 31/37] refactor: centralize token target dispatch Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dist/main.cjs | 59 ++++++++++------- lib/main.js | 64 +++++++++++-------- ...nterprise-mutual-exclusivity-owner.test.js | 3 +- ...se-mutual-exclusivity-repositories.test.js | 3 +- 4 files changed, 78 insertions(+), 51 deletions(-) diff --git a/dist/main.cjs b/dist/main.cjs index 07854ee3..0d28a9fd 100644 --- a/dist/main.cjs +++ b/dist/main.cjs @@ -23206,29 +23206,10 @@ async function main(clientId, privateKey, enterprise, owner, repositories, permi privateKey, request: request2 }); - let authentication, installationId, appSlug; - if (target.type === "enterprise") { - ({ authentication, installationId, appSlug } = await pRetry( - () => getTokenFromEnterprise(request2, auth5, target.enterprise, permissions), - createTokenRetryOptions(core, `enterprise "${target.enterprise}"`) - )); - } else if (target.type === "repository") { - ({ authentication, installationId, appSlug } = await pRetry( - () => getTokenFromRepository( - request2, - auth5, - target.owner, - target.repositories, - permissions - ), - createTokenRetryOptions(core, `"${target.repositories.join(",")}"`) - )); - } else { - ({ authentication, installationId, appSlug } = await pRetry( - () => getTokenFromOwner(request2, auth5, target.owner, permissions), - createTokenRetryOptions(core, `"${target.owner}"`) - )); - } + const { authentication, installationId, appSlug } = await pRetry( + () => getTokenFromTarget(request2, auth5, target, permissions), + createTokenRetryOptions(core, getTokenRetryDescription(target)) + ); core.setSecret(authentication.token); core.setOutput("token", authentication.token); core.setOutput("installation-id", installationId); @@ -23279,6 +23260,38 @@ function resolveInstallationTarget(enterprise, owner, repositories, core) { repositories }; } +function getTokenRetryDescription(target) { + switch (target.type) { + case "enterprise": + return `enterprise "${target.enterprise}"`; + case "repository": + return `"${target.repositories.join(",")}"`; + case "owner": + return `"${target.owner}"`; + /* c8 ignore next 2 */ + default: + throw new Error(`Unsupported installation target type: ${target.type}`); + } +} +function getTokenFromTarget(request2, auth5, target, permissions) { + switch (target.type) { + case "enterprise": + return getTokenFromEnterprise(request2, auth5, target.enterprise, permissions); + case "repository": + return getTokenFromRepository( + request2, + auth5, + target.owner, + target.repositories, + permissions + ); + case "owner": + return getTokenFromOwner(request2, auth5, target.owner, permissions); + /* c8 ignore next 2 */ + default: + throw new Error(`Unsupported installation target type: ${target.type}`); + } +} function createTokenRetryOptions(core, targetDescription) { return { shouldRetry: ({ error: error2 }) => error2.status >= 500, diff --git a/lib/main.js b/lib/main.js index 04c3f978..f7782a9d 100644 --- a/lib/main.js +++ b/lib/main.js @@ -38,32 +38,10 @@ export async function main( request, }); - let authentication, installationId, appSlug; - - if (target.type === "enterprise") { - ({ authentication, installationId, appSlug } = await pRetry( - () => getTokenFromEnterprise(request, auth, target.enterprise, permissions), - createTokenRetryOptions(core, `enterprise "${target.enterprise}"`) - )); - } else if (target.type === "repository") { - ({ authentication, installationId, appSlug } = await pRetry( - () => - getTokenFromRepository( - request, - auth, - target.owner, - target.repositories, - permissions - ), - createTokenRetryOptions(core, `"${target.repositories.join(",")}"`) - )); - } else { - // Otherwise get the installation for the owner, which can either be an organization or a user account - ({ authentication, installationId, appSlug } = await pRetry( - () => getTokenFromOwner(request, auth, target.owner, permissions), - createTokenRetryOptions(core, `"${target.owner}"`) - )); - } + 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); @@ -129,6 +107,40 @@ function resolveInstallationTarget(enterprise, owner, repositories, core) { }; } +function getTokenRetryDescription(target) { + switch (target.type) { + case "enterprise": + return `enterprise "${target.enterprise}"`; + case "repository": + return `"${target.repositories.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, diff --git a/tests/main-enterprise-mutual-exclusivity-owner.test.js b/tests/main-enterprise-mutual-exclusivity-owner.test.js index 4d0f00b8..2c2b4131 100644 --- a/tests/main-enterprise-mutual-exclusivity-owner.test.js +++ b/tests/main-enterprise-mutual-exclusivity-owner.test.js @@ -9,4 +9,5 @@ for (const [key, value] of Object.entries(DEFAULT_ENV)) { process.env.INPUT_ENTERPRISE = "test-enterprise"; process.env.INPUT_OWNER = "test-owner"; -await import("../main.js"); +const { default: promise } = await import("../main.js"); +await promise; diff --git a/tests/main-enterprise-mutual-exclusivity-repositories.test.js b/tests/main-enterprise-mutual-exclusivity-repositories.test.js index 00c70b72..795a82cc 100644 --- a/tests/main-enterprise-mutual-exclusivity-repositories.test.js +++ b/tests/main-enterprise-mutual-exclusivity-repositories.test.js @@ -9,4 +9,5 @@ for (const [key, value] of Object.entries(DEFAULT_ENV)) { process.env.INPUT_ENTERPRISE = "test-enterprise"; process.env.INPUT_REPOSITORIES = "repo1,repo2"; -await import("../main.js"); +const { default: promise } = await import("../main.js"); +await promise; From 6d2a54a91668c9b460a5a85e66d8f1e85e8c4b82 Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Fri, 8 May 2026 00:25:10 -0700 Subject: [PATCH 32/37] docs: clarify enterprise token inputs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 21 +++++++++++---------- action.yml | 2 +- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index efcd8539..43257164 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ jobs: id: app-token with: client-id: ${{ vars.APP_CLIENT_ID }} - private-key: ${{ secrets.PRIVATE_KEY }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} enterprise: my-enterprise-slug - name: Call enterprise management REST API with gh run: | @@ -220,7 +220,7 @@ jobs: ### Create a token with specific permissions > [!NOTE] -> Selected permissions must be granted to the installation of the specified app and repository owner. Setting a permission that the installation does not have will result in an error. +> Selected permissions must be granted to the specified app installation. Setting a permission that the installation does not have will result in an error. ```yaml on: [issues] @@ -380,10 +380,10 @@ steps: ### `enterprise` -**Optional:** The slug version of the enterprise name to generate a token for enterprise-level app installations. +**Optional:** The slug of the enterprise account to generate a token for an enterprise installation. > [!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. +> The `enterprise` input is mutually exclusive with `owner` and `repositories`. Use it when the GitHub App is installed on an enterprise account. Enterprise installation tokens can call enterprise APIs, but do not grant organization or repository access. ### `permission-` @@ -415,13 +415,14 @@ GitHub App slug. ## How it works -The action creates an installation access token using [the `POST /app/installations/{installation_id}/access_tokens` endpoint](https://docs.github.com/rest/apps/apps?apiVersion=2022-11-28#create-an-installation-access-token-for-an-app). By default, +The action creates an installation access token using [the `POST /app/installations/{installation_id}/access_tokens` endpoint](https://docs.github.com/rest/apps/apps?apiVersion=2022-11-28#create-an-installation-access-token-for-an-app). + +The token target depends on the inputs: `enterprise` creates a token for an enterprise installation, `owner` without `repositories` creates a token for all repositories in the owner's installation, `repositories` scopes the token to those repositories, and no target inputs scopes the token to the current repository. -1. The token is scoped to the current repository or `repositories` if set. -2. The token inherits all the installation's permissions. -3. The token is set as output `token` which can be used in subsequent steps. -4. Unless the `skip-token-revoke` input is set to true, the token is revoked in the `post` step of the action, which means it cannot be passed to another job. -5. The token is masked, it cannot be logged accidentally. +1. The token inherits all the installation's permissions. +2. The token is set as output `token` which can be used in subsequent steps. +3. Unless the `skip-token-revoke` input is set to true, the token is revoked in the `post` step of the action, which means it cannot be passed to another job. +4. The token is masked, it cannot be logged accidentally. > [!NOTE] > Installation permissions can differ from the app's permissions they belong to. Installation permissions are set when an app is installed on an account. When the app adds more permissions after the installation, an account administrator will have to approve the new permissions before they are set on the installation. diff --git a/action.yml b/action.yml index 7146bbe9..a4e11487 100644 --- a/action.yml +++ b/action.yml @@ -22,7 +22,7 @@ inputs: 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')" + description: "The slug of the enterprise account where the GitHub App is installed (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" From 6cc1810e49424c3918535821793499004dd60e5c Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Fri, 8 May 2026 00:42:45 -0700 Subject: [PATCH 33/37] test: use generated enterprise permission input Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/index.js.snapshot | 2 +- tests/main-enterprise-token-with-permissions.test.js | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/index.js.snapshot b/tests/index.js.snapshot index a8954b2d..dd2713db 100644 --- a/tests/index.js.snapshot +++ b/tests/index.js.snapshot @@ -151,7 +151,7 @@ Creating enterprise installation token for enterprise "test-enterprise". --- REQUESTS --- GET /enterprises/test-enterprise/installation POST /app/installations/123456/access_tokens -{"permissions":{"enterprise_organizations":"read","enterprise_people":"write"}} +{"permissions":{"enterprise_custom_properties_for_organizations":"read"}} `; exports[`main-missing-client-and-app-id.test.js > stderr 1`] = ` diff --git a/tests/main-enterprise-token-with-permissions.test.js b/tests/main-enterprise-token-with-permissions.test.js index f1a7914c..460e613a 100644 --- a/tests/main-enterprise-token-with-permissions.test.js +++ b/tests/main-enterprise-token-with-permissions.test.js @@ -5,8 +5,9 @@ await test((mockPool) => { process.env.INPUT_ENTERPRISE = "test-enterprise"; delete process.env.INPUT_OWNER; delete process.env.INPUT_REPOSITORIES; - process.env["INPUT_PERMISSION-ENTERPRISE-ORGANIZATIONS"] = "read"; - process.env["INPUT_PERMISSION-ENTERPRISE-PEOPLE"] = "write"; + process.env[ + "INPUT_PERMISSION-ENTERPRISE-CUSTOM-PROPERTIES-FOR-ORGANIZATIONS" + ] = "read"; // Mock the enterprise installation endpoint const mockInstallationId = "123456"; From e9a267e0cd03cfcbbf680a396c2aa280df65822d Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Fri, 8 May 2026 10:31:30 -0700 Subject: [PATCH 34/37] test: remove misleading enterprise permission case The generic permissions test already covers forwarding permission inputs into installation token creation. Remove the enterprise-specific case because the only currently generated enterprise permission does not match the documented installable-organizations API example. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/index.js.snapshot | 17 ---------- ...-enterprise-token-with-permissions.test.js | 33 ------------------- 2 files changed, 50 deletions(-) delete mode 100644 tests/main-enterprise-token-with-permissions.test.js diff --git a/tests/index.js.snapshot b/tests/index.js.snapshot index dd2713db..974acf41 100644 --- a/tests/index.js.snapshot +++ b/tests/index.js.snapshot @@ -137,23 +137,6 @@ POST /app/installations/123456/access_tokens null `; -exports[`main-enterprise-token-with-permissions.test.js > stdout 1`] = ` -Creating enterprise installation token for enterprise "test-enterprise". -::add-mask::ghs_16C7e42F292c6912E7710c838347Ae178B4a - -::set-output name=token::ghs_16C7e42F292c6912E7710c838347Ae178B4a - -::set-output name=installation-id::123456 - -::set-output name=app-slug::github-actions -::save-state name=token::ghs_16C7e42F292c6912E7710c838347Ae178B4a -::save-state name=expiresAt::2016-07-11T22:14:10Z ---- REQUESTS --- -GET /enterprises/test-enterprise/installation -POST /app/installations/123456/access_tokens -{"permissions":{"enterprise_custom_properties_for_organizations":"read"}} -`; - exports[`main-missing-client-and-app-id.test.js > stderr 1`] = ` The 'client-id' (or deprecated 'app-id') input must be set to a non-empty string. If using a secret or variable, ensure it is available in this workflow context. `; diff --git a/tests/main-enterprise-token-with-permissions.test.js b/tests/main-enterprise-token-with-permissions.test.js deleted file mode 100644 index 460e613a..00000000 --- a/tests/main-enterprise-token-with-permissions.test.js +++ /dev/null @@ -1,33 +0,0 @@ -import { test } from "./main.js"; - -// Verify `main` successfully generates enterprise token with specific permissions. -await test((mockPool) => { - process.env.INPUT_ENTERPRISE = "test-enterprise"; - delete process.env.INPUT_OWNER; - delete process.env.INPUT_REPOSITORIES; - process.env[ - "INPUT_PERMISSION-ENTERPRISE-CUSTOM-PROPERTIES-FOR-ORGANIZATIONS" - ] = "read"; - - // Mock the enterprise installation endpoint - const mockInstallationId = "123456"; - const mockAppSlug = "github-actions"; - mockPool - .intercept({ - path: "/enterprises/test-enterprise/installation", - method: "GET", - headers: { - accept: "application/vnd.github.v3+json", - "user-agent": "actions/create-github-app-token", - // Intentionally omitting the `authorization` header, since JWT creation is not idempotent. - }, - }) - .reply( - 200, - { - id: mockInstallationId, - app_slug: mockAppSlug, - }, - { headers: { "content-type": "application/json" } } - ); -}); From a109b1cf9a04a0a25f2b55e9d7d3237e1e9b2a27 Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Fri, 8 May 2026 10:44:09 -0700 Subject: [PATCH 35/37] test: restore enterprise permission forwarding coverage Keep coverage for the enterprise token path forwarding permission inputs into installation token creation. Use a generated enterprise permission input instead of the undeclared enterprise-organizations and enterprise-people inputs from the original test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/index.js.snapshot | 17 ++++++++++ ...n-enterprise-token-permissions-set.test.js | 34 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 tests/main-enterprise-token-permissions-set.test.js diff --git a/tests/index.js.snapshot b/tests/index.js.snapshot index 974acf41..3b63f9f1 100644 --- a/tests/index.js.snapshot +++ b/tests/index.js.snapshot @@ -137,6 +137,23 @@ POST /app/installations/123456/access_tokens null `; +exports[`main-enterprise-token-permissions-set.test.js > stdout 1`] = ` +Creating enterprise installation token for enterprise "test-enterprise". +::add-mask::ghs_16C7e42F292c6912E7710c838347Ae178B4a + +::set-output name=token::ghs_16C7e42F292c6912E7710c838347Ae178B4a + +::set-output name=installation-id::123456 + +::set-output name=app-slug::github-actions +::save-state name=token::ghs_16C7e42F292c6912E7710c838347Ae178B4a +::save-state name=expiresAt::2016-07-11T22:14:10Z +--- REQUESTS --- +GET /enterprises/test-enterprise/installation +POST /app/installations/123456/access_tokens +{"permissions":{"enterprise_custom_properties_for_organizations":"read"}} +`; + exports[`main-missing-client-and-app-id.test.js > stderr 1`] = ` The 'client-id' (or deprecated 'app-id') input must be set to a non-empty string. If using a secret or variable, ensure it is available in this workflow context. `; diff --git a/tests/main-enterprise-token-permissions-set.test.js b/tests/main-enterprise-token-permissions-set.test.js new file mode 100644 index 00000000..2073e7b3 --- /dev/null +++ b/tests/main-enterprise-token-permissions-set.test.js @@ -0,0 +1,34 @@ +import { test } from "./main.js"; + +// Use a declared enterprise permission from the generated schema to verify +// enterprise token requests forward permission inputs to token creation. +await test((mockPool) => { + process.env.INPUT_ENTERPRISE = "test-enterprise"; + delete process.env.INPUT_OWNER; + delete process.env.INPUT_REPOSITORIES; + process.env[ + "INPUT_PERMISSION-ENTERPRISE-CUSTOM-PROPERTIES-FOR-ORGANIZATIONS" + ] = "read"; + + // Mock the enterprise installation endpoint + const mockInstallationId = "123456"; + const mockAppSlug = "github-actions"; + mockPool + .intercept({ + path: "/enterprises/test-enterprise/installation", + method: "GET", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": "actions/create-github-app-token", + // Intentionally omitting the `authorization` header, since JWT creation is not idempotent. + }, + }) + .reply( + 200, + { + id: mockInstallationId, + app_slug: mockAppSlug, + }, + { headers: { "content-type": "application/json" } } + ); +}); From 4ba4a1392905afa3735f421baa3e013d2ff460dd Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Fri, 8 May 2026 10:51:01 -0700 Subject: [PATCH 36/37] fix: include owner in repository retry logs Make repository retry failure logs self-contained by formatting repository targets as owner/repo instead of only repo names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dist/main.cjs | 2 +- lib/main.js | 4 +++- tests/index.js.snapshot | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/dist/main.cjs b/dist/main.cjs index 0d28a9fd..d5e7f6d1 100644 --- a/dist/main.cjs +++ b/dist/main.cjs @@ -23265,7 +23265,7 @@ function getTokenRetryDescription(target) { case "enterprise": return `enterprise "${target.enterprise}"`; case "repository": - return `"${target.repositories.join(",")}"`; + return `"${target.repositories.map((repository) => `${target.owner}/${repository}`).join(",")}"`; case "owner": return `"${target.owner}"`; /* c8 ignore next 2 */ diff --git a/lib/main.js b/lib/main.js index f7782a9d..f8f8f1c4 100644 --- a/lib/main.js +++ b/lib/main.js @@ -112,7 +112,9 @@ function getTokenRetryDescription(target) { case "enterprise": return `enterprise "${target.enterprise}"`; case "repository": - return `"${target.repositories.join(",")}"`; + return `"${target.repositories + .map((repository) => `${target.owner}/${repository}`) + .join(",")}"`; case "owner": return `"${target.owner}"`; /* c8 ignore next 2 */ diff --git a/tests/index.js.snapshot b/tests/index.js.snapshot index 3b63f9f1..1d368935 100644 --- a/tests/index.js.snapshot +++ b/tests/index.js.snapshot @@ -282,7 +282,7 @@ exports[`main-token-get-owner-set-repo-fail-response.test.js > stdout 1`] = ` Inputs 'owner' and 'repositories' are set. Creating token for the following repositories: - actions/failed-repo -Failed to create token for "failed-repo" (attempt 1): GitHub API not available +Failed to create token for "actions/failed-repo" (attempt 1): GitHub API not available ::add-mask::ghs_16C7e42F292c6912E7710c838347Ae178B4a ::set-output name=token::ghs_16C7e42F292c6912E7710c838347Ae178B4a From 6f5d39fa9c2b931c077e7ff7f6cc7a181968ccdf Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Fri, 8 May 2026 11:42:46 -0700 Subject: [PATCH 37/37] fix: retry transient token network errors Retry recognized network errors in addition to HTTP 5xx responses when creating installation tokens, while preserving immediate failure for 4xx client errors. Also clarify the enterprise installation 404 message with the quoted enterprise slug. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dist/main.cjs | 9 ++--- lib/main.js | 10 +++-- package-lock.json | 7 ++-- package.json | 1 + tests/index.js.snapshot | 32 ++++++++++----- ...n-get-owner-set-repo-network-error.test.js | 39 +++++++++++++++++++ 6 files changed, 77 insertions(+), 21 deletions(-) create mode 100644 tests/main-token-get-owner-set-repo-network-error.test.js diff --git a/dist/main.cjs b/dist/main.cjs index d5e7f6d1..dc716995 100644 --- a/dist/main.cjs +++ b/dist/main.cjs @@ -22985,7 +22985,7 @@ function isNetworkError(error2) { return false; } const { message, stack } = error2; - if (message === "Load failed") { + if (message === "Load failed" || message.startsWith("Load failed (") && message.endsWith(")")) { return stack === void 0 || "__sentry_captured__" in error2; } if (message.startsWith("error sending request for url")) { @@ -23249,8 +23249,7 @@ function resolveInstallationTarget(enterprise, owner, repositories, core) { ); } else { core.info( - `Inputs 'owner' and 'repositories' are set. Creating token for the following repositories: - ${repositories.map((repo) => ` + `Inputs 'owner' and 'repositories' are set. Creating token for the following repositories:${repositories.map((repo) => ` - ${parsedOwner}/${repo}`).join("")}` ); } @@ -23294,7 +23293,7 @@ function getTokenFromTarget(request2, auth5, target, permissions) { } function createTokenRetryOptions(core, targetDescription) { return { - shouldRetry: ({ error: error2 }) => error2.status >= 500, + shouldRetry: ({ error: error2 }) => error2.status >= 500 || isNetworkError(error2), onFailedAttempt: (context) => { core.info( `Failed to create token for ${targetDescription} (attempt ${context.attemptNumber}): ${context.error.message}` @@ -23349,7 +23348,7 @@ async function getTokenFromEnterprise(request2, auth5, enterprise, permissions) } catch (error2) { if (error2.status === 404) { throw new Error( - `No enterprise installation found matching the name ${enterprise}.` + `No enterprise installation found matching the enterprise slug "${enterprise}".` ); } throw error2; diff --git a/lib/main.js b/lib/main.js index f8f8f1c4..8fe0942a 100644 --- a/lib/main.js +++ b/lib/main.js @@ -1,4 +1,5 @@ import pRetry from "p-retry"; +import isNetworkError from "is-network-error"; // @ts-check /** @@ -95,8 +96,9 @@ function resolveInstallationTarget(enterprise, owner, repositories, core) { ); } else { core.info( - `Inputs 'owner' and 'repositories' are set. Creating token for the following repositories: - ${repositories.map((repo) => `\n- ${parsedOwner}/${repo}`).join("")}` + `Inputs 'owner' and 'repositories' are set. Creating token for the following repositories:${repositories + .map((repo) => `\n- ${parsedOwner}/${repo}`) + .join("")}` ); } @@ -145,7 +147,7 @@ function getTokenFromTarget(request, auth, target, permissions) { function createTokenRetryOptions(core, targetDescription) { return { - shouldRetry: ({ error }) => error.status >= 500, + shouldRetry: ({ error }) => error.status >= 500 || isNetworkError(error), onFailedAttempt: (context) => { core.info( `Failed to create token for ${targetDescription} (attempt ${context.attemptNumber}): ${context.error.message}` @@ -223,7 +225,7 @@ async function getTokenFromEnterprise(request, auth, enterprise, permissions) { } catch (error) { if (error.status === 404) { throw new Error( - `No enterprise installation found matching the name ${enterprise}.` + `No enterprise installation found matching the enterprise slug "${enterprise}".` ); } diff --git a/package-lock.json b/package-lock.json index 83791b4d..aa183a77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@actions/core": "^3.0.1", "@octokit/auth-app": "^8.2.0", "@octokit/request": "^10.0.8", + "is-network-error": "^1.3.2", "p-retry": "^8.0.0" }, "devDependencies": { @@ -1252,9 +1253,9 @@ } }, "node_modules/is-network-error": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz", - "integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", + "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", "license": "MIT", "engines": { "node": ">=16" diff --git a/package.json b/package.json index 5a36f79c..60bceff9 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@actions/core": "^3.0.1", "@octokit/auth-app": "^8.2.0", "@octokit/request": "^10.0.8", + "is-network-error": "^1.3.2", "p-retry": "^8.0.0" }, "devDependencies": { diff --git a/tests/index.js.snapshot b/tests/index.js.snapshot index 1d368935..a77b6fa1 100644 --- a/tests/index.js.snapshot +++ b/tests/index.js.snapshot @@ -38,7 +38,6 @@ POST /app/installations/123456/access_tokens exports[`main-custom-github-api-url.test.js > stdout 1`] = ` Inputs 'owner' and 'repositories' are set. Creating token for the following repositories: - - actions/create-github-app-token ::add-mask::ghs_16C7e42F292c6912E7710c838347Ae178B4a @@ -75,7 +74,7 @@ null `; exports[`main-enterprise-installation-not-found.test.js > stderr 1`] = ` -Error: No enterprise installation found matching the name test-enterprise. +Error: No enterprise installation found matching the enterprise slug "test-enterprise". at getTokenFromEnterprise (file:///lib/main.js::) at process.processTicksAndRejections (node:internal/process/task_queues::) at async pRetry (file:///node_modules/p-retry/index.js::) @@ -86,8 +85,8 @@ Error: No enterprise installation found matching the name test-enterprise. exports[`main-enterprise-installation-not-found.test.js > stdout 1`] = ` Creating enterprise installation token for enterprise "test-enterprise". -Failed to create token for enterprise "test-enterprise" (attempt 1): No enterprise installation found matching the name test-enterprise. -::error::No enterprise installation found matching the name test-enterprise. +Failed to create token for enterprise "test-enterprise" (attempt 1): No enterprise installation found matching the enterprise slug "test-enterprise". +::error::No enterprise installation found matching the enterprise slug "test-enterprise". --- REQUESTS --- GET /enterprises/test-enterprise/installation `; @@ -202,7 +201,6 @@ exports[`main-repo-skew.test.js > stderr 1`] = ` exports[`main-repo-skew.test.js > stdout 1`] = ` Inputs 'owner' and 'repositories' are set. Creating token for the following repositories: - - actions/failed-repo ::add-mask::ghs_16C7e42F292c6912E7710c838347Ae178B4a @@ -280,7 +278,6 @@ null exports[`main-token-get-owner-set-repo-fail-response.test.js > stdout 1`] = ` Inputs 'owner' and 'repositories' are set. Creating token for the following repositories: - - actions/failed-repo Failed to create token for "actions/failed-repo" (attempt 1): GitHub API not available ::add-mask::ghs_16C7e42F292c6912E7710c838347Ae178B4a @@ -299,9 +296,28 @@ POST /app/installations/123456/access_tokens {"repositories":["failed-repo"]} `; +exports[`main-token-get-owner-set-repo-network-error.test.js > stdout 1`] = ` +Inputs 'owner' and 'repositories' are set. Creating token for the following repositories: +- actions/network-repo +Failed to create token for "actions/network-repo" (attempt 1): fetch failed +::add-mask::ghs_16C7e42F292c6912E7710c838347Ae178B4a + +::set-output name=token::ghs_16C7e42F292c6912E7710c838347Ae178B4a + +::set-output name=installation-id::123456 + +::set-output name=app-slug::github-actions +::save-state name=token::ghs_16C7e42F292c6912E7710c838347Ae178B4a +::save-state name=expiresAt::2016-07-11T22:14:10Z +--- REQUESTS --- +GET /repos/actions/network-repo/installation +GET /repos/actions/network-repo/installation +POST /app/installations/123456/access_tokens +{"repositories":["network-repo"]} +`; + exports[`main-token-get-owner-set-repo-set-to-many-newline.test.js > stdout 1`] = ` Inputs 'owner' and 'repositories' are set. Creating token for the following repositories: - - actions/create-github-app-token - actions/toolkit - actions/checkout @@ -322,7 +338,6 @@ POST /app/installations/123456/access_tokens exports[`main-token-get-owner-set-repo-set-to-many.test.js > stdout 1`] = ` Inputs 'owner' and 'repositories' are set. Creating token for the following repositories: - - actions/create-github-app-token - actions/toolkit - actions/checkout @@ -343,7 +358,6 @@ POST /app/installations/123456/access_tokens exports[`main-token-get-owner-set-repo-set-to-one.test.js > stdout 1`] = ` Inputs 'owner' and 'repositories' are set. Creating token for the following repositories: - - actions/create-github-app-token ::add-mask::ghs_16C7e42F292c6912E7710c838347Ae178B4a diff --git a/tests/main-token-get-owner-set-repo-network-error.test.js b/tests/main-token-get-owner-set-repo-network-error.test.js new file mode 100644 index 00000000..3da78f9e --- /dev/null +++ b/tests/main-token-get-owner-set-repo-network-error.test.js @@ -0,0 +1,39 @@ +import { test } from "./main.js"; + +// Verify transient network errors are retried when getting a repository token. +await test((mockPool) => { + process.env.INPUT_OWNER = "actions"; + process.env.INPUT_REPOSITORIES = "network-repo"; + const owner = process.env.INPUT_OWNER; + const repo = process.env.INPUT_REPOSITORIES; + const mockInstallationId = "123456"; + const mockAppSlug = "github-actions"; + + mockPool + .intercept({ + path: `/repos/${owner}/${repo}/installation`, + method: "GET", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": "actions/create-github-app-token", + // Intentionally omitting the `authorization` header, since JWT creation is not idempotent. + }, + }) + .replyWithError(new TypeError("fetch failed")); + + mockPool + .intercept({ + path: `/repos/${owner}/${repo}/installation`, + method: "GET", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": "actions/create-github-app-token", + // Intentionally omitting the `authorization` header, since JWT creation is not idempotent. + }, + }) + .reply( + 200, + { id: mockInstallationId, app_slug: mockAppSlug }, + { headers: { "content-type": "application/json" } }, + ); +});