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..efcd853 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@v3 + id: app-token + with: + client-id: ${{ vars.APP_CLIENT_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] @@ -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 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. + ### `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/action.yml b/action.yml index ce9b276..7146bbe 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 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" required: false diff --git a/dist/main.cjs b/dist/main.cjs index b9dc621..fddd08e 100644 --- a/dist/main.cjs +++ b/dist/main.cjs @@ -22964,37 +22964,30 @@ 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; } - 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; + if (error2.message === "Load failed") { + return error2.stack === void 0; } - return errorMessages.has(message); + return errorMessages.has(error2.message); } // node_modules/p-retry/index.js @@ -23024,14 +23017,6 @@ 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(); @@ -23059,26 +23044,6 @@ 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) { @@ -23086,60 +23051,55 @@ 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, - retryDelay: effectiveDelay + retriesConsumed }); 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)) { - throw normalizedError; + if (consumeRetry) { + throw normalizedError; + } + options.signal?.throwIfAborted(); + return false; } 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 finalDelay = Math.min(effectiveDelay, remainingTimeAfterShouldRetry); + const delayTime = calculateDelay(retriesConsumed, options); + const finalDelay = Math.min(delayTime, remainingTime); options.signal?.throwIfAborted(); - await delayForRetry(finalDelay, options); + 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 }); + }); + } options.signal?.throwIfAborted(); return true; } @@ -23159,9 +23119,6 @@ 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 }); @@ -23196,78 +23153,37 @@ async function pRetry(input, options = {}) { } // lib/main.js -async function main(clientId, privateKey, owner, repositories, permissions, core, createAppAuth2, request2, skipTokenRevoke) { - 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) => ` -- ${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("")}` - ); +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 }); let authentication, installationId, appSlug; - if (parsedRepositoryNames.length > 0) { + 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, - 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 - } + createTokenRetryOptions(core, `"${target.repositories.join(",")}"`) )); } 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 - } + () => getTokenFromOwner(request2, auth5, target.owner, permissions), + createTokenRetryOptions(core, `"${target.owner}"`) )); } core.setSecret(authentication.token); @@ -23279,6 +23195,71 @@ async function main(clientId, privateKey, owner, repositories, permissions, core 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 + }; +} +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", + 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 +23267,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 +23277,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 name ${enterprise}.` + ); + } + throw error2; + } + return createInstallationAuthResult(auth5, response.data, permissions); } // lib/request.js @@ -23355,6 +23342,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 +23350,7 @@ async function run() { return main( clientId, privateKey, + enterprise, owner, repositories, permissions, diff --git a/lib/main.js b/lib/main.js index 8f5ef9a..04c3f97 100644 --- a/lib/main.js +++ b/lib/main.js @@ -4,6 +4,7 @@ import pRetry from "p-retry"; /** * @param {string} clientId * @param {string} privateKey + * @param {string} enterprise * @param {string} owner * @param {string[]} repositories * @param {undefined | Record} permissions @@ -15,59 +16,21 @@ import pRetry from "p-retry"; export async function main( clientId, privateKey, + enterprise, owner, repositories, permissions, core, createAppAuth, request, - skipTokenRevoke + skipTokenRevoke, ) { - let parsedOwner = ""; - let parsedRepositoryNames = []; - - // If neither owner nor repositories are set, default to current repository - if (!owner && repositories.length === 0) { - const [owner, repo] = String(process.env.GITHUB_REPOSITORY).split("/"); - parsedOwner = owner; - parsedRepositoryNames = [repo]; - - core.info( - `Inputs 'owner' and 'repositories' are not set. Creating token for this repository (${owner}/${repo}).` - ); + // 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 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("")}` - ); - } + const target = resolveInstallationTarget(enterprise, owner, repositories, core); const auth = createAppAuth({ appId: clientId, @@ -76,42 +39,29 @@ 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 (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, - parsedOwner, - parsedRepositoryNames, + target.owner, + target.repositories, 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, - } + 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, parsedOwner, permissions), - { - onFailedAttempt: (context) => { - core.info( - `Failed to create token for "${parsedOwner}" (attempt ${context.attemptNumber}): ${context.error.message}` - ); - }, - retries: 3, - } + () => getTokenFromOwner(request, auth, target.owner, permissions), + createTokenRetryOptions(core, `"${target.owner}"`) )); } @@ -129,6 +79,88 @@ 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, + }; +} + +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, + 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 @@ -139,17 +171,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 +192,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 name ${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/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..a8954b2 100644 --- a/tests/index.js.snapshot +++ b/tests/index.js.snapshot @@ -55,6 +55,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 name 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 name test-enterprise. +::error::No enterprise installation found matching the name 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-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_organizations":"read","enterprise_people":"write"}} +`; + 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. `; @@ -121,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-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..4d0f00b --- /dev/null +++ b/tests/main-enterprise-mutual-exclusivity-owner.test.js @@ -0,0 +1,12 @@ +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"; + +await import("../main.js"); 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..00c70b7 --- /dev/null +++ b/tests/main-enterprise-mutual-exclusivity-repositories.test.js @@ -0,0 +1,12 @@ +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"; + +await import("../main.js"); 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-with-permissions.test.js b/tests/main-enterprise-token-with-permissions.test.js new file mode 100644 index 0000000..f1a7914 --- /dev/null +++ b/tests/main-enterprise-token-with-permissions.test.js @@ -0,0 +1,32 @@ +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 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" } }, + ); +});