diff --git a/.gitignore b/.gitignore index b443287..87966f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env coverage node_modules/ +.DS_Store diff --git a/README.md b/README.md index 2073f97..4325716 100644 --- a/README.md +++ b/README.md @@ -195,10 +195,32 @@ jobs: body: "Hello, World!" ``` +### Create a token for an enterprise installation + +```yaml +on: [workflow_dispatch] + +jobs: + hello-world: + runs-on: ubuntu-latest + steps: + - uses: actions/create-github-app-token@v3 + id: app-token + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_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] -> 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] @@ -356,6 +378,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 account to generate a token for an enterprise installation. + +> [!NOTE] +> 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-` **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`). @@ -386,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 ce9b276..a4e1148 100644 --- a/action.yml +++ b/action.yml @@ -21,6 +21,9 @@ inputs: repositories: description: "Comma or newline-separated list of repositories to install the GitHub App on (defaults to current repository if owner is unset)" required: false + enterprise: + description: "The slug 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" required: false diff --git a/dist/main.cjs b/dist/main.cjs index b9dc621..dc71699 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")) { @@ -23196,89 +23196,125 @@ async function pRetry(input, options = {}) { } // lib/main.js -async function main(clientId, privateKey, owner, repositories, permissions, core, createAppAuth2, request2, skipTokenRevoke) { - let parsedOwner = ""; - let parsedRepositoryNames = []; +async function main(clientId, privateKey, enterprise, owner, repositories, permissions, core, createAppAuth2, request2, skipTokenRevoke) { + if (enterprise && (owner || repositories.length > 0)) { + throw new Error("Cannot use 'enterprise' input with 'owner' or 'repositories' inputs"); + } + const target = resolveInstallationTarget(enterprise, owner, repositories, core); + const auth5 = createAppAuth2({ + appId: clientId, + privateKey, + request: request2 + }); + 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); + core.setOutput("app-slug", appSlug); + if (!skipTokenRevoke) { + core.saveState("token", authentication.token); + core.saveState("expiresAt", authentication.expiresAt); + } +} +function resolveInstallationTarget(enterprise, owner, repositories, core) { + if (enterprise) { + core.info(`Creating enterprise installation token for enterprise "${enterprise}".`); + return { type: "enterprise", enterprise }; + } if (!owner && repositories.length === 0) { - const [owner2, repo] = String(process.env.GITHUB_REPOSITORY).split("/"); - parsedOwner = owner2; - parsedRepositoryNames = [repo]; + const [defaultOwner, repo] = String(process.env.GITHUB_REPOSITORY).split("/"); core.info( - `Inputs 'owner' and 'repositories' are not set. Creating token for this repository (${owner2}/${repo}).` + `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) { - parsedOwner = owner; core.info( `Input 'repositories' is not set. Creating token for all repositories owned by ${owner}.` ); + return { type: "owner", owner }; } - if (!owner && repositories.length > 0) { - parsedOwner = String(process.env.GITHUB_REPOSITORY_OWNER); - parsedRepositoryNames = repositories; + 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("")}` ); - } - if (owner && repositories.length > 0) { - parsedOwner = owner; - parsedRepositoryNames = repositories; + } 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("")}` ); } - const auth5 = createAppAuth2({ - appId: clientId, - privateKey, - request: request2 - }); - let authentication, installationId, appSlug; - if (parsedRepositoryNames.length > 0) { - ({ authentication, installationId, appSlug } = await pRetry( - () => getTokenFromRepository( + return { + type: "repository", + owner: parsedOwner, + repositories + }; +} +function getTokenRetryDescription(target) { + switch (target.type) { + case "enterprise": + return `enterprise "${target.enterprise}"`; + case "repository": + return `"${target.repositories.map((repository) => `${target.owner}/${repository}`).join(",")}"`; + case "owner": + return `"${target.owner}"`; + /* c8 ignore next 2 */ + default: + throw new Error(`Unsupported installation target type: ${target.type}`); + } +} +function getTokenFromTarget(request2, auth5, target, permissions) { + switch (target.type) { + case "enterprise": + return getTokenFromEnterprise(request2, auth5, target.enterprise, permissions); + case "repository": + return 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( - "," - )}" (attempt ${context.attemptNumber}): ${context.error.message}` - ); - }, - retries: 3 - } - )); - } else { - ({ authentication, installationId, appSlug } = await pRetry( - () => getTokenFromOwner(request2, auth5, parsedOwner, permissions), - { - onFailedAttempt: (context) => { - core.info( - `Failed to create token for "${parsedOwner}" (attempt ${context.attemptNumber}): ${context.error.message}` - ); - }, - retries: 3 - } - )); - } - core.setSecret(authentication.token); - core.setOutput("token", authentication.token); - core.setOutput("installation-id", installationId); - core.setOutput("app-slug", appSlug); - if (!skipTokenRevoke) { - core.saveState("token", authentication.token); - core.saveState("expiresAt", authentication.expiresAt); + ); + 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 || isNetworkError(error2), + 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", + 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, @@ -23286,14 +23322,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", { @@ -23303,15 +23332,28 @@ async function getTokenFromRepository(request2, auth5, parsedOwner, parsedReposi hook: auth5.hook } }); - const authentication = await auth5({ - type: "installation", - installationId: response.data.id, - repositoryNames: parsedRepositoryNames, - permissions + return createInstallationAuthResult(auth5, response.data, permissions, { + repositoryNames: parsedRepositoryNames }); - const installationId = response.data.id; - const appSlug = response.data["app_slug"]; - return { authentication, installationId, appSlug }; +} +async function getTokenFromEnterprise(request2, auth5, enterprise, permissions) { + let response; + try { + response = await request2("GET /enterprises/{enterprise}/installation", { + enterprise, + request: { + hook: auth5.hook + } + }); + } catch (error2) { + if (error2.status === 404) { + throw new Error( + `No enterprise installation found matching the enterprise slug "${enterprise}".` + ); + } + throw error2; + } + return createInstallationAuthResult(auth5, response.data, permissions); } // lib/request.js @@ -23355,6 +23397,7 @@ async function run() { throw new Error("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."); } const privateKey = getInput("private-key"); + const enterprise = getInput("enterprise"); const owner = getInput("owner"); const repositories = getInput("repositories").split(/[\n,]+/).map((s) => s.trim()).filter((x) => x !== ""); const skipTokenRevoke = getBooleanInput("skip-token-revoke"); @@ -23362,6 +23405,7 @@ async function run() { return main( clientId, privateKey, + enterprise, owner, repositories, permissions, diff --git a/lib/main.js b/lib/main.js index 8f5ef9a..8fe0942 100644 --- a/lib/main.js +++ b/lib/main.js @@ -1,9 +1,11 @@ import pRetry from "p-retry"; +import isNetworkError from "is-network-error"; // @ts-check /** * @param {string} clientId * @param {string} privateKey + * @param {string} enterprise * @param {string} owner * @param {string[]} repositories * @param {undefined | Record} permissions @@ -15,118 +17,164 @@ import pRetry from "p-retry"; export async function main( clientId, privateKey, + enterprise, owner, repositories, permissions, core, createAppAuth, request, - skipTokenRevoke + skipTokenRevoke, ) { - let parsedOwner = ""; - let parsedRepositoryNames = []; + // Validate mutual exclusivity of enterprise with owner/repositories + if (enterprise && (owner || repositories.length > 0)) { + throw new Error("Cannot use 'enterprise' input with 'owner' or 'repositories' inputs"); + } + + const target = resolveInstallationTarget(enterprise, owner, repositories, core); + + const auth = createAppAuth({ + appId: clientId, + privateKey, + request, + }); + + const { authentication, installationId, appSlug } = await pRetry( + () => getTokenFromTarget(request, auth, target, permissions), + createTokenRetryOptions(core, getTokenRetryDescription(target)) + ); + + // Register the token with the runner as a secret to ensure it is masked in logs + core.setSecret(authentication.token); + + core.setOutput("token", authentication.token); + core.setOutput("installation-id", installationId); + core.setOutput("app-slug", appSlug); + + // Make token accessible to post function (so we can invalidate it) + if (!skipTokenRevoke) { + core.saveState("token", authentication.token); + core.saveState("expiresAt", authentication.expiresAt); + } +} + +function resolveInstallationTarget(enterprise, owner, repositories, core) { + if (enterprise) { + core.info(`Creating enterprise installation token for enterprise "${enterprise}".`); + return { type: "enterprise", enterprise }; + } - // If 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]; + const [defaultOwner, repo] = String(process.env.GITHUB_REPOSITORY).split("/"); core.info( - `Inputs 'owner' and 'repositories' are not set. Creating token for this repository (${owner}/${repo}).` + `Inputs 'owner' and 'repositories' are not set. Creating token for this repository (${defaultOwner}/${repo}).` ); + + return { + type: "repository", + owner: defaultOwner, + repositories: [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}.` ); + + return { type: "owner", 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; + 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("")}` ); - } - - // If both owner and repositories are set, use those values - if (owner && repositories.length > 0) { - parsedOwner = owner; - parsedRepositoryNames = repositories; - + } 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("")}` ); } - const auth = createAppAuth({ - appId: clientId, - privateKey, - request, - }); + return { + type: "repository", + owner: parsedOwner, + repositories, + }; +} - let authentication, installationId, appSlug; - // If at least one repository is set, get installation ID from that repository - - if (parsedRepositoryNames.length > 0) { - ({ authentication, installationId, appSlug } = await pRetry( - () => - getTokenFromRepository( - request, - auth, - parsedOwner, - parsedRepositoryNames, - permissions - ), - { - shouldRetry: ({ error }) => error.status >= 500, - onFailedAttempt: (context) => { - core.info( - `Failed to create token for "${parsedRepositoryNames.join( - "," - )}" (attempt ${context.attemptNumber}): ${context.error.message}` - ); - }, - retries: 3, - } - )); - } 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), - { - onFailedAttempt: (context) => { - core.info( - `Failed to create token for "${parsedOwner}" (attempt ${context.attemptNumber}): ${context.error.message}` - ); - }, - retries: 3, - } - )); +function getTokenRetryDescription(target) { + switch (target.type) { + case "enterprise": + return `enterprise "${target.enterprise}"`; + case "repository": + return `"${target.repositories + .map((repository) => `${target.owner}/${repository}`) + .join(",")}"`; + case "owner": + return `"${target.owner}"`; + /* c8 ignore next 2 */ + default: + throw new Error(`Unsupported installation target type: ${target.type}`); } +} - // Register the token with the runner as a secret to ensure it is masked in logs - core.setSecret(authentication.token); +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}`); + } +} - core.setOutput("token", authentication.token); - core.setOutput("installation-id", installationId); - core.setOutput("app-slug", appSlug); +function createTokenRetryOptions(core, targetDescription) { + return { + shouldRetry: ({ error }) => error.status >= 500 || isNetworkError(error), + onFailedAttempt: (context) => { + core.info( + `Failed to create token for ${targetDescription} (attempt ${context.attemptNumber}): ${context.error.message}` + ); + }, + retries: 3, + }; +} - // Make token accessible to post function (so we can invalidate it) - if (!skipTokenRevoke) { - core.saveState("token", authentication.token); - core.saveState("expiresAt", authentication.expiresAt); - } +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) { @@ -139,17 +187,8 @@ async function getTokenFromOwner(request, auth, parsedOwner, permissions) { }, }); - // Get token for 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 }; + // Get token for all repositories of the given installation + return createInstallationAuthResult(auth, response.data, permissions); } async function getTokenFromRepository( @@ -169,15 +208,30 @@ 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"]; +async function getTokenFromEnterprise(request, auth, enterprise, permissions) { + let response; + try { + response = await request("GET /enterprises/{enterprise}/installation", { + enterprise, + request: { + hook: auth.hook, + }, + }); + } catch (error) { + if (error.status === 404) { + throw new Error( + `No enterprise installation found matching the enterprise slug "${enterprise}".` + ); + } + + throw error; + } - return { authentication, installationId, appSlug }; + // Get token for the enterprise installation + return createInstallationAuthResult(auth, response.data, permissions); } diff --git a/main.js b/main.js index 32696dd..a44409a 100644 --- a/main.js +++ b/main.js @@ -23,6 +23,7 @@ async function run() { throw new Error("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."); } const privateKey = core.getInput("private-key"); + const enterprise = core.getInput("enterprise"); const owner = core.getInput("owner"); const repositories = core .getInput("repositories") @@ -37,6 +38,7 @@ async function run() { return main( clientId, privateKey, + enterprise, owner, repositories, permissions, diff --git a/package-lock.json b/package-lock.json index 83791b4..aa183a7 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 5a36f79..60bceff 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 b/tests/index.js index d3e2521..74cf272 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"); @@ -39,10 +46,19 @@ for (const file of testFiles) { NODE_USE_ENV_PROXY, ...env } = process.env; - const { stderr, stdout } = await execFileAsync("node", [`tests/${file}`], { - env, - }); - const trimmedStderr = stderr.replace(/\r?\n$/, ""); + 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) => { if (trimmedStderr) t.assert.snapshot(trimmedStderr); diff --git a/tests/index.js.snapshot b/tests/index.js.snapshot index 4789f44..a77b6fa 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 @@ -55,6 +54,105 @@ 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 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::) + 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`] = ` +Creating enterprise installation token for enterprise "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 +`; + +exports[`main-enterprise-mutual-exclusivity-owner.test.js > stderr 1`] = ` +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:: + 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-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::) + 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-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 + +::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-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. `; @@ -103,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 @@ -121,6 +218,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 @@ -142,9 +278,8 @@ 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 "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 @@ -161,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 @@ -184,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 @@ -205,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-enterprise-fail-response.test.js b/tests/main-enterprise-fail-response.test.js new file mode 100644 index 0000000..068e2cb --- /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" } }, + ); +}); 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 0000000..a578967 --- /dev/null +++ b/tests/main-enterprise-installation-not-found.test.js @@ -0,0 +1,25 @@ +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; + process.env.INPUT_ENTERPRISE = "test-enterprise"; + + // Mock the enterprise installation endpoint to return no matching installation + 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( + 404, + { message: "Not Found" }, + { headers: { "content-type": "application/json" } } + ); +}); 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 0000000..2c2b413 --- /dev/null +++ b/tests/main-enterprise-mutual-exclusivity-owner.test.js @@ -0,0 +1,13 @@ +import { DEFAULT_ENV } from "./main.js"; + +// Verify `main` exits with an error when `enterprise` is used with `owner` input. +// 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"; + +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 new file mode 100644 index 0000000..795a82c --- /dev/null +++ b/tests/main-enterprise-mutual-exclusivity-repositories.test.js @@ -0,0 +1,13 @@ +import { DEFAULT_ENV } from "./main.js"; + +// Verify `main` exits with an error when `enterprise` is used with `repositories` input. +// 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"; + +const { default: promise } = await import("../main.js"); +await promise; diff --git a/tests/main-enterprise-only-success.test.js b/tests/main-enterprise-only-success.test.js new file mode 100644 index 0000000..5008375 --- /dev/null +++ b/tests/main-enterprise-only-success.test.js @@ -0,0 +1,30 @@ +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 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" } } + ); +}); 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 0000000..2073e7b --- /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" } } + ); +}); 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 0000000..237b405 --- /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" } }, + ); +}); 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 0000000..3da78f9 --- /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" } }, + ); +});