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 b57c702..b8f763c 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: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.PRIVATE_KEY }} + enterprise-slug: my-enterprise-slug + - name: Call enterprise management REST API with gh + run: | + gh api /enterprises/my-enterprise-slug/apps/installable_organizations + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} +``` + ### Create a token with specific permissions > [!NOTE] @@ -353,6 +375,13 @@ steps: > [!NOTE] > If `owner` is set and `repositories` is empty, access will be scoped to all repositories in the provided repository owner's installation. If `owner` and `repositories` are empty, access will be scoped to only the current repository. +### `enterprise-slug` + +**Optional:** The slug of the enterprise to generate a token for enterprise-level app installations. + +> [!NOTE] +> The `enterprise-slug` input is mutually exclusive with `owner` and `repositories`. GitHub Apps can be installed on enterprise accounts with permissions that let them call enterprise management APIs. Enterprise installations do not grant access to organization or repository resources. + ### `permission-` **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 ba4e915..0c1e044 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-slug: + 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/lib/main.js b/lib/main.js index 9ae9d78..9489cea 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} enterpriseSlug * @param {string} owner * @param {string[]} repositories * @param {undefined | Record} permissions @@ -15,58 +16,69 @@ import pRetry from "p-retry"; export async function main( appId, privateKey, + enterpriseSlug, owner, repositories, permissions, core, createAppAuth, request, - skipTokenRevoke + skipTokenRevoke, ) { - let parsedOwner = ""; - let parsedRepositoryNames = []; - - // If neither owner nor repositories are set, default to current repository - if (!owner && repositories.length === 0) { - const [owner, repo] = String(process.env.GITHUB_REPOSITORY).split("/"); - parsedOwner = owner; - parsedRepositoryNames = [repo]; - - core.info( - `Inputs 'owner' and 'repositories' are not set. Creating token for this repository (${owner}/${repo}).` - ); - } - - // If only an owner is set, default to all repositories from that owner - if (owner && repositories.length === 0) { - parsedOwner = owner; - - core.info( - `Input 'repositories' is not set. Creating token for all repositories owned by ${owner}.` - ); - } - - // If repositories are set, but no owner, default to `GITHUB_REPOSITORY_OWNER` - if (!owner && repositories.length > 0) { - parsedOwner = String(process.env.GITHUB_REPOSITORY_OWNER); - parsedRepositoryNames = repositories; - - core.info( - `No 'owner' input provided. Using default owner '${parsedOwner}' to create token for the following repositories:${repositories - .map((repo) => `\n- ${parsedOwner}/${repo}`) - .join("")}` - ); + // Validate mutual exclusivity of enterprise-slug with owner/repositories + if (enterpriseSlug && (owner || repositories.length > 0)) { + throw new Error("Cannot use 'enterprise-slug' input with 'owner' or 'repositories' inputs"); } - // If both owner and repositories are set, use those values - if (owner && repositories.length > 0) { - parsedOwner = owner; - parsedRepositoryNames = repositories; + let parsedOwner = ""; + let parsedRepositoryNames = []; - core.info( - `Inputs 'owner' and 'repositories' are set. Creating token for the following repositories: + // Skip owner/repository parsing if enterprise-slug is set + if (!enterpriseSlug) { + // If neither owner nor repositories are set, default to current repository + if (!owner && repositories.length === 0) { + const [owner, repo] = String(process.env.GITHUB_REPOSITORY).split("/"); + parsedOwner = owner; + parsedRepositoryNames = [repo]; + + core.info( + `Inputs 'owner' and 'repositories' are not set. Creating token for this repository (${owner}/${repo}).` + ); + } + + // If only an owner is set, default to all repositories from that owner + if (owner && repositories.length === 0) { + parsedOwner = owner; + + core.info( + `Input 'repositories' is not set. Creating token for all repositories owned by ${owner}.` + ); + } + + // If repositories are set, but no owner, default to `GITHUB_REPOSITORY_OWNER` + if (!owner && repositories.length > 0) { + parsedOwner = String(process.env.GITHUB_REPOSITORY_OWNER); + parsedRepositoryNames = repositories; + + core.info( + `No 'owner' input provided. Using default owner '${parsedOwner}' to create token for the following repositories:${repositories + .map((repo) => `\n- ${parsedOwner}/${repo}`) + .join("")}` + ); + } + + // If both owner and repositories are set, use those values + if (owner && repositories.length > 0) { + parsedOwner = owner; + parsedRepositoryNames = repositories; + + core.info( + `Inputs 'owner' and 'repositories' are set. Creating token for the following repositories: ${repositories.map((repo) => `\n- ${parsedOwner}/${repo}`).join("")}` - ); + ); + } + } else { + core.info(`Creating enterprise installation token for enterprise "${enterpriseSlug}".`); } const auth = createAppAuth({ @@ -76,9 +88,22 @@ export async function main( }); let authentication, installationId, appSlug; - // If at least one repository is set, get installation ID from that repository - - if (parsedRepositoryNames.length > 0) { + + // If enterprise-slug is set, get installation ID from the enterprise + if (enterpriseSlug) { + ({ authentication, installationId, appSlug } = await pRetry( + () => getTokenFromEnterprise(request, auth, enterpriseSlug, permissions), + { + shouldRetry: ({ error }) => error.status >= 500, + onFailedAttempt: (context) => { + core.info( + `Failed to create token for enterprise "${enterpriseSlug}" (attempt ${context.attemptNumber}): ${context.error.message}` + ); + }, + retries: 3, + } + )); + } else if (parsedRepositoryNames.length > 0) { ({ authentication, installationId, appSlug } = await pRetry( () => getTokenFromRepository( @@ -181,3 +206,36 @@ async function getTokenFromRepository( return { authentication, installationId, appSlug }; } + +async function getTokenFromEnterprise(request, auth, enterpriseSlug, permissions) { + let response; + try { + response = await request("GET /enterprises/{enterprise}/installation", { + enterprise: enterpriseSlug, + request: { + hook: auth.hook, + }, + }); + } catch (error) { + /* c8 ignore next 8 */ + if (error.status === 404) { + throw new Error( + `No enterprise installation found matching the name ${enterpriseSlug}.` + ); + } + + throw error; + } + + // Get token for the enterprise installation + const authentication = await auth({ + type: "installation", + installationId: response.data.id, + permissions, + }); + + const installationId = response.data.id; + const appSlug = response.data["app_slug"]; + + return { authentication, installationId, appSlug }; +} diff --git a/main.js b/main.js index d8ebee4..63662f0 100644 --- a/main.js +++ b/main.js @@ -20,6 +20,7 @@ async function run() { const appId = core.getInput("app-id"); const privateKey = core.getInput("private-key"); + const enterpriseSlug = core.getInput("enterprise-slug"); const owner = core.getInput("owner"); const repositories = core .getInput("repositories") @@ -34,6 +35,7 @@ async function run() { return main( appId, privateKey, + enterpriseSlug, owner, repositories, permissions, @@ -46,7 +48,10 @@ async function run() { // Export promise for testing export default run().catch((error) => { - /* c8 ignore next 3 */ + /* c8 ignore next 5 */ 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/tests/index.js b/tests/index.js index d3e2521..11ce4a8 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 06cac80..e9700a2 100644 --- a/tests/index.js.snapshot +++ b/tests/index.js.snapshot @@ -17,6 +17,77 @@ POST /api/v3/app/installations/123456/access_tokens {"repositories":["create-github-app-token"]} `; +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. +--- REQUESTS --- +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:///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:///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`] = ` +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-owner.test.js > stderr 1`] = ` GITHUB_REPOSITORY_OWNER missing, must be set to '' `; @@ -46,10 +117,6 @@ 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. 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..0c36a26 --- /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-SLUG"] = "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..eaa36da --- /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-slug` is used with `owner` input. +try { + // 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-SLUG"] = "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 0000000..c69f0f0 --- /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-slug` is used with `repositories` input. +try { + // 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-SLUG"] = "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 0000000..7f696ef --- /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-slug` input is set. +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" } } + ); +}); 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..9f9c0cc --- /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-SLUG"] = "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" } } + ); +});