├── .gitignore ├── src ├── state.ts ├── run.ts ├── revoke-installation-access-token.ts ├── main.ts ├── post.ts ├── create-installation-access-token.ts ├── parse-options.ts └── installation-retrieval.ts ├── tsconfig.json ├── prettier.config.js ├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── LICENSE ├── package.json ├── action.yml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | -------------------------------------------------------------------------------- /src/state.ts: -------------------------------------------------------------------------------- 1 | export const tokenKey = "token"; 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "forceConsistentCasingInFileNames": true, 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "noEmit": true, 7 | "strict": true, 8 | "target": "ES2022", 9 | "types": ["node"] 10 | }, 11 | "include": ["src"] 12 | } 13 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | importOrder: ["^node:(.*)$", "", "^[./]"], 3 | importOrderGroupNamespaceSpecifiers: true, 4 | importOrderSeparation: true, 5 | importOrderSortSpecifiers: true, 6 | plugins: [ 7 | "@trivago/prettier-plugin-sort-imports", 8 | "prettier-plugin-packagejson", 9 | ], 10 | }; 11 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | publish: 9 | name: Publish 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: dylanvann/publish-github-action@v1.1.49 14 | with: 15 | github_token: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /src/run.ts: -------------------------------------------------------------------------------- 1 | import { setFailed } from "@actions/core"; 2 | 3 | export const run = async (callback: () => Promise) => { 4 | try { 5 | await callback(); 6 | } catch (error) { 7 | // Using `console.error()` instead of only passing `error` to `setFailed()` for better error reporting. 8 | // See https://github.com/actions/toolkit/issues/1527. 9 | console.error(error); 10 | setFailed(""); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/revoke-installation-access-token.ts: -------------------------------------------------------------------------------- 1 | import { getOctokit } from "@actions/github"; 2 | 3 | export const revokeInstallationAccessToken = async ( 4 | token: string, 5 | ): Promise => { 6 | try { 7 | const octokit = getOctokit(token); 8 | 9 | await octokit.request("DELETE /installation/token"); 10 | } catch (error: unknown) { 11 | throw new Error("Could not revoke installation access token.", { 12 | cause: error, 13 | }); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { info, saveState, setOutput, setSecret } from "@actions/core"; 2 | 3 | import { createInstallationAccessToken } from "./create-installation-access-token.js"; 4 | import { parseOptions } from "./parse-options.js"; 5 | import { run } from "./run.js"; 6 | import { tokenKey } from "./state.js"; 7 | 8 | await run(async () => { 9 | const options = parseOptions(); 10 | const token = await createInstallationAccessToken(options); 11 | setSecret(token); 12 | saveState(tokenKey, token); 13 | setOutput("token", token); 14 | info("Token created successfully"); 15 | }); 16 | -------------------------------------------------------------------------------- /src/post.ts: -------------------------------------------------------------------------------- 1 | import { getInput, getState, info } from "@actions/core"; 2 | 3 | import { revokeInstallationAccessToken } from "./revoke-installation-access-token.js"; 4 | import { run } from "./run.js"; 5 | import { tokenKey } from "./state.js"; 6 | 7 | await run(async () => { 8 | if (!JSON.parse(getInput("revoke"))) { 9 | info("Token revocation skipped"); 10 | return; 11 | } 12 | 13 | const token = getState(tokenKey); 14 | if (!token) { 15 | info("No token to revoke"); 16 | return; 17 | } 18 | await revokeInstallationAccessToken(token); 19 | info("Token revoked successfully"); 20 | }); 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches-ignore: 5 | - main 6 | 7 | jobs: 8 | test: 9 | name: Test 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 20 16 | cache: npm 17 | - run: npm ci 18 | - run: npm run typecheck 19 | - run: npm run build 20 | # Optional integration test of the action using a dedicated GitHub App. 21 | - id: create_token 22 | if: ${{ vars.TEST_GITHUB_APP_ID != '' }} 23 | uses: ./ 24 | with: 25 | # The only required permission is `Repository permissions > Metadata: Read-only`. 26 | app_id: ${{ vars.TEST_GITHUB_APP_ID }} 27 | private_key: ${{ secrets.TEST_GITHUB_APP_PRIVATE_KEY }} 28 | - if: ${{ steps.create_token.outcome != 'skipped' }} 29 | run: node --eval "assert('${{ steps.create_token.outputs.token }}'.length > 0);" 30 | - run: npm run prettier -- --check 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2023 Thibault Derousseaux 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-app-token", 3 | "version": "2.1.0", 4 | "license": "MIT", 5 | "type": "module", 6 | "files": [ 7 | "action.yml", 8 | "dist" 9 | ], 10 | "scripts": { 11 | "build": "npm run build:main && npm run build:post", 12 | "build:main": "npm run compile -- --out ./dist/main src/main.ts ", 13 | "build:post": "npm run compile -- --out ./dist/post src/post.ts", 14 | "compile": "ncc build --minify --no-cache --target es2022 --v8-cache", 15 | "prettier": "prettier --ignore-path .gitignore \"./**/*.{js,json,md,ts,yml}\"", 16 | "typecheck": "tsc --build" 17 | }, 18 | "dependencies": { 19 | "@actions/core": "^1.10.1", 20 | "@actions/github": "^5.1.1", 21 | "@octokit/auth-app": "^6.0.0", 22 | "@octokit/request": "^8.1.1", 23 | "is-base64": "^1.1.0" 24 | }, 25 | "devDependencies": { 26 | "@trivago/prettier-plugin-sort-imports": "^4.2.0", 27 | "@types/is-base64": "^1.1.1", 28 | "@types/node": "^20.6.2", 29 | "@vercel/ncc": "^0.38.0", 30 | "prettier": "^3.0.3", 31 | "prettier-plugin-packagejson": "^2.4.5", 32 | "typescript": "^5.2.2" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/create-installation-access-token.ts: -------------------------------------------------------------------------------- 1 | import { getOctokit } from "@actions/github"; 2 | import { createAppAuth } from "@octokit/auth-app"; 3 | import { request } from "@octokit/request"; 4 | 5 | import { 6 | InstallationRetrievalDetails, 7 | retrieveInstallationId, 8 | } from "./installation-retrieval.js"; 9 | 10 | export type InstallationAccessTokenCreationOptions = Readonly<{ 11 | appId: string; 12 | githubApiUrl: URL; 13 | installationRetrievalDetails: InstallationRetrievalDetails; 14 | permissions?: Record; 15 | privateKey: string; 16 | repositories?: string[]; 17 | }>; 18 | 19 | export const createInstallationAccessToken = async ({ 20 | appId, 21 | githubApiUrl, 22 | installationRetrievalDetails, 23 | permissions, 24 | privateKey, 25 | repositories, 26 | }: InstallationAccessTokenCreationOptions): Promise => { 27 | try { 28 | const app = createAppAuth({ 29 | appId, 30 | privateKey, 31 | request: request.defaults({ 32 | baseUrl: githubApiUrl 33 | .toString() 34 | // Remove optional trailing `/`. 35 | .replace(/\/$/, ""), 36 | }), 37 | }); 38 | 39 | const authApp = await app({ type: "app" }); 40 | const octokit = getOctokit(authApp.token); 41 | 42 | const installationId = await retrieveInstallationId( 43 | installationRetrievalDetails, 44 | { octokit }, 45 | ); 46 | 47 | const { 48 | data: { token }, 49 | } = await octokit.request( 50 | "POST /app/installations/{installation_id}/access_tokens", 51 | { installation_id: installationId, permissions, repositories }, 52 | ); 53 | return token; 54 | } catch (error: unknown) { 55 | throw new Error("Could not create installation access token.", { 56 | cause: error, 57 | }); 58 | } 59 | }; 60 | -------------------------------------------------------------------------------- /src/parse-options.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from "node:buffer"; 2 | 3 | import { debug, getInput } from "@actions/core"; 4 | import isBase64 from "is-base64"; 5 | 6 | import { InstallationAccessTokenCreationOptions } from "./create-installation-access-token.js"; 7 | import { getInstallationRetrievalDetails } from "./installation-retrieval.js"; 8 | 9 | export const parseOptions = (): InstallationAccessTokenCreationOptions => { 10 | const appId = getInput("app_id", { required: true }); 11 | 12 | const githubApiUrlInput = getInput("github_api_url", { required: true }); 13 | const githubApiUrl = new URL(githubApiUrlInput); 14 | 15 | const installationRetrievalMode = getInput("installation_retrieval_mode", { 16 | required: true, 17 | }); 18 | const installationRetrievalPayload = getInput( 19 | "installation_retrieval_payload", 20 | { required: true }, 21 | ); 22 | const installationRetrievalDetails = getInstallationRetrievalDetails({ 23 | mode: installationRetrievalMode, 24 | payload: installationRetrievalPayload, 25 | }); 26 | debug( 27 | `Installation retrieval details: ${JSON.stringify( 28 | installationRetrievalDetails, 29 | )}.`, 30 | ); 31 | 32 | const permissionsInput = getInput("permissions"); 33 | const permissions = permissionsInput 34 | ? (JSON.parse(permissionsInput) as Record) 35 | : undefined; 36 | debug(`Requested permissions: ${JSON.stringify(permissions)}.`); 37 | 38 | const privateKeyInput = getInput("private_key", { required: true }); 39 | const privateKey = isBase64(privateKeyInput) 40 | ? Buffer.from(privateKeyInput, "base64").toString("utf8") 41 | : privateKeyInput; 42 | 43 | const repositoriesInput = getInput("repositories"); 44 | const repositories = repositoriesInput 45 | ? (JSON.parse(repositoriesInput) as string[]) 46 | : undefined; 47 | debug(`Requested repositories: ${JSON.stringify(repositories)}.`); 48 | 49 | return { 50 | appId, 51 | githubApiUrl, 52 | installationRetrievalDetails, 53 | permissions, 54 | privateKey, 55 | repositories, 56 | }; 57 | }; 58 | -------------------------------------------------------------------------------- /src/installation-retrieval.ts: -------------------------------------------------------------------------------- 1 | import { debug } from "@actions/core"; 2 | import { getOctokit } from "@actions/github"; 3 | 4 | export type InstallationRetrievalDetails = Readonly< 5 | | { mode: "id"; id: number } 6 | | { mode: "organization"; org: string } 7 | | { mode: "repository"; owner: string; repo: string } 8 | | { mode: "user"; username: string } 9 | >; 10 | 11 | export const getInstallationRetrievalDetails = ({ 12 | mode, 13 | payload, 14 | }: Readonly<{ 15 | mode: string; 16 | payload: string; 17 | }>): InstallationRetrievalDetails => { 18 | switch (mode) { 19 | case "id": 20 | return { mode, id: parseInt(payload) }; 21 | case "organization": 22 | return { mode, org: payload }; 23 | case "repository": 24 | const [owner, repo] = payload.split("/"); 25 | return { mode, owner, repo }; 26 | case "user": 27 | return { mode, username: payload }; 28 | default: 29 | throw new Error(`Unsupported retrieval mode: "${mode}".`); 30 | } 31 | }; 32 | 33 | export const retrieveInstallationId = async ( 34 | details: InstallationRetrievalDetails, 35 | { octokit }: Readonly<{ octokit: ReturnType }>, 36 | ): Promise => { 37 | let id: number; 38 | try { 39 | switch (details.mode) { 40 | case "id": 41 | ({ id } = details); 42 | break; 43 | case "organization": 44 | ({ 45 | data: { id }, 46 | } = await octokit.request("GET /orgs/{org}/installation", { 47 | org: details.org, 48 | })); 49 | break; 50 | case "repository": 51 | ({ 52 | data: { id }, 53 | } = await octokit.request("GET /repos/{owner}/{repo}/installation", { 54 | owner: details.owner, 55 | repo: details.repo, 56 | })); 57 | break; 58 | case "user": 59 | ({ 60 | data: { id }, 61 | } = await octokit.request("GET /users/{username}/installation", { 62 | username: details.username, 63 | })); 64 | break; 65 | } 66 | } catch (error: unknown) { 67 | throw new Error("Could not retrieve installation.", { cause: error }); 68 | } 69 | 70 | debug(`Retrieved installation ID: ${id}.`); 71 | return id; 72 | }; 73 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: GitHub App token 2 | author: Thibault Derousseaux 3 | description: Run a GitHub Action as a GitHub App instead of using secrets.GITHUB_TOKEN or a personal access token. 4 | inputs: 5 | app_id: 6 | description: ID of the GitHub App. 7 | required: true 8 | github_api_url: 9 | description: The API URL of the GitHub server. 10 | default: ${{ github.api_url }} 11 | installation_retrieval_mode: 12 | description: >- 13 | The mode used to retrieve the installation for which the token will be requested. 14 | 15 | One of: 16 | - id: use the installation with the specified ID. 17 | - organization: use an organization installation (https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#get-an-organization-installation-for-the-authenticated-app). 18 | - repository: use a repository installation (https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#get-a-repository-installation-for-the-authenticated-app). 19 | - user: use a user installation (https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#get-a-user-installation-for-the-authenticated-app). 20 | default: repository 21 | installation_retrieval_payload: 22 | description: >- 23 | The payload used to retrieve the installation. 24 | 25 | Examples for each retrieval mode: 26 | - id: 1337 27 | - organization: github 28 | - repository: tibdex/github-app-token 29 | - user: tibdex 30 | default: ${{ github.repository }} 31 | permissions: 32 | description: >- 33 | The JSON-stringified permissions granted to the token. 34 | Defaults to all permissions granted to the GitHub app. 35 | See https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#create-an-installation-access-token-for-an-app's `permissions`. 36 | private_key: 37 | description: Private key of the GitHub App (can be Base64 encoded). 38 | required: true 39 | repositories: 40 | description: >- 41 | The JSON-stringified array of the full names of the repositories the token should have access to. 42 | Defaults to all repositories that the installation can access. 43 | See https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#create-an-installation-access-token-for-an-app's `repositories`. 44 | revoke: 45 | description: Revoke the token at the end of the job. 46 | default: true 47 | outputs: 48 | token: 49 | description: An installation access token for the GitHub App. 50 | runs: 51 | using: node20 52 | main: dist/main/index.js 53 | post: dist/post/index.js 54 | branding: 55 | icon: unlock 56 | color: gray-dark 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!IMPORTANT] 2 | > This action is deprecated, use https://github.com/actions/create-github-app-token instead. 3 | 4 | # GitHub App Token 5 | 6 | This [JavaScript GitHub Action](https://help.github.com/en/actions/building-actions/about-actions#javascript-actions) can be used to impersonate a GitHub App when `secrets.GITHUB_TOKEN`'s limitations are too restrictive and a personal access token is not suitable. 7 | 8 | For instance, from [GitHub Actions' docs](https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow): 9 | 10 | > When you use the repository's `GITHUB_TOKEN` to perform tasks, events triggered by the `GITHUB_TOKEN`, with the exception of `workflow_dispatch` and `repository_dispatch`, will not create a new workflow run. 11 | > This prevents you from accidentally creating recursive workflow runs. 12 | > For example, if a workflow run pushes code using the repository's `GITHUB_TOKEN`, a new workflow will not run even when the repository contains a workflow configured to run when push events occur. 13 | 14 | A workaround is to use a [personal access token](https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line) from a [personal user/bot account](https://help.github.com/en/github/getting-started-with-github/types-of-github-accounts#personal-user-accounts). 15 | However, for organizations, GitHub Apps are [a more appropriate automation solution](https://developer.github.com/apps/differences-between-apps/#machine-vs-bot-accounts). 16 | 17 | # Example Workflow 18 | 19 | ```yml 20 | jobs: 21 | job: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - id: create_token 25 | uses: tibdex/github-app-token@v2 26 | with: 27 | app_id: ${{ secrets.APP_ID }} 28 | 29 | # Optional. 30 | # github_api_url: https://api.example.com 31 | 32 | # Optional. 33 | # installation_retrieval_mode: id 34 | 35 | # Optional. 36 | # installation_retrieval_payload: 1337 37 | 38 | # Optional. 39 | # Using a YAML multiline string to avoid escaping the JSON quotes. 40 | # permissions: >- 41 | # {"pull_requests": "read"} 42 | 43 | private_key: ${{ secrets.PRIVATE_KEY }} 44 | 45 | # Optional. 46 | # repositories: >- 47 | # ["actions/toolkit", "github/docs"] 48 | 49 | # Optional. 50 | # revoke: false 51 | 52 | - run: "echo 'The created token is masked: ${{ steps.create_token.outputs.token }}'" 53 | ``` 54 | 55 | [Another use case for this action can (or could) be found in GitHub's own docs](https://web.archive.org/web/20230115194214/https://docs.github.com/en/issues/planning-and-tracking-with-projects/automating-your-project/automating-projects-using-actions#example-workflow-authenticating-with-a-github-app). 56 | --------------------------------------------------------------------------------