Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.env
coverage
node_modules/
.DS_Store
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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-<permission name>`

**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`).
Expand Down
3 changes: 3 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
146 changes: 102 additions & 44 deletions lib/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>} permissions
Expand All @@ -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({
Expand All @@ -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(
Expand Down Expand Up @@ -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 };
}
9 changes: 7 additions & 2 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -34,6 +35,7 @@ async function run() {
return main(
appId,
privateKey,
enterpriseSlug,
owner,
repositories,
permissions,
Expand All @@ -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);
}
});
9 changes: 8 additions & 1 deletion tests/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(), "<cwd>")
.replaceAll(/:\d+:\d+/g, ":<line>:<column>");
}

// Get all files in tests directory
const files = readdirSync("tests");

Expand Down Expand Up @@ -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);
Expand Down
75 changes: 71 additions & 4 deletions tests/index.js.snapshot
Original file line number Diff line number Diff line change
Expand Up @@ -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://<cwd>/lib/main.js:<line>:<column>)
at process.processTicksAndRejections (node:internal/process/task_queues:<line>:<column>)
at async pRetry (file://<cwd>/node_modules/p-retry/index.js:<line>:<column>)
at async main (file://<cwd>/lib/main.js:<line>:<column>)
at async test (file://<cwd>/tests/main.js:<line>:<column>)
at async file://<cwd>/tests/main-enterprise-installation-not-found.test.js:<line>:<column>
`;

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://<cwd>/lib/main.js:<line>:<column>)
at run (file://<cwd>/main.js:<line>:<column>)
at file://<cwd>/main.js:<line>:<column>
at ModuleJob.run (node:internal/modules/esm/module_job:<line>:<column>)
at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:<line>:<column>)
at async file://<cwd>/tests/main-enterprise-mutual-exclusivity-owner.test.js:<line>:<column>
`;

exports[`main-enterprise-mutual-exclusivity-repositories.test.js > stderr 1`] = `
Error: Cannot use 'enterprise-slug' input with 'owner' or 'repositories' inputs
at main (file://<cwd>/lib/main.js:<line>:<column>)
at run (file://<cwd>/main.js:<line>:<column>)
at file://<cwd>/main.js:<line>:<column>
at ModuleJob.run (node:internal/modules/esm/module_job:<line>:<column>)
at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:<line>:<column>)
at async file://<cwd>/tests/main-enterprise-mutual-exclusivity-repositories.test.js:<line>:<column>
`;

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 '<owner>'
`;
Expand Down Expand Up @@ -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.
Expand Down
Loading