Compare commits

..

6 Commits

Author SHA1 Message Date
Parker Brown 5acda85bb8 ci: use existing release tag format
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 18:39:55 -07:00
Parker Brown a7508c5d49 ci: build release assets from release workflow
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 18:00:59 -07:00
Parker Brown 21ee484d31 ci: restrict release build to bot PRs
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 17:52:07 -07:00
Parker Brown fb92a08ca2 ci: skip release build for forks
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 17:42:33 -07:00
Parker Brown 470e9c179e ci: pin octokit request action
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 17:41:13 -07:00
Parker Brown 4289413635 Migrate releases to release-please
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 18:26:08 -07:00
19 changed files with 284 additions and 607 deletions
+53 -12
View File
@@ -1,6 +1,7 @@
name: release
on:
workflow_dispatch:
push:
branches:
- "*.x"
@@ -17,24 +18,64 @@ jobs:
name: release
runs-on: ubuntu-latest
steps:
# build local version to create token
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: package.json
- run: npm ci
- run: npm run build
- uses: ./
id: app-token
with:
app-id: ${{ vars.RELEASER_APP_ID }}
private-key: ${{ secrets.RELEASER_APP_PRIVATE_KEY }}
# install release dependencies and release
- run: npm install --no-save @semantic-release/git semantic-release-plugin-github-breaking-version-tag
- run: npx semantic-release --debug
- uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
id: release-please
with:
token: ${{ steps.app-token.outputs.token }}
config-file: ${{ github.ref_name == 'beta' && 'release-please-config.beta.json' || 'release-please-config.json' }}
manifest-file: .release-please-manifest.json
target-branch: ${{ github.ref_name }}
- uses: actions/checkout@v6
if: steps.release-please.outputs.prs_created == 'true'
with:
ref: ${{ fromJSON(steps.release-please.outputs.pr).headBranchName }}
token: ${{ steps.app-token.outputs.token }}
- uses: actions/setup-node@v6
if: steps.release-please.outputs.prs_created == 'true'
with:
node-version-file: package.json
- run: npm ci
if: steps.release-please.outputs.prs_created == 'true'
- run: npm run build
if: steps.release-please.outputs.prs_created == 'true'
- uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0
if: steps.release-please.outputs.prs_created == 'true'
with:
commit_author: "${{ github.actor }} <${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com>"
commit_message: "chore: update dist files"
file_pattern: dist/**
- name: Update major version tag
id: update-major-tag
if: steps.release-please.outputs.release_created == 'true' && github.ref_name != 'beta'
uses: octokit/request-action@b91aabaa861c777dcdb14e2387e30eddf04619ae # v3.0.0
continue-on-error: true
with:
route: PATCH /repos/${{ github.repository }}/git/refs/tags/v${{ steps.release-please.outputs.major }}
sha: ${{ steps.release-please.outputs.sha }}
force: true
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
- name: Create major version tag
if: steps.release-please.outputs.release_created == 'true' && github.ref_name != 'beta' && steps.update-major-tag.outcome == 'failure'
uses: octokit/request-action@b91aabaa861c777dcdb14e2387e30eddf04619ae # v3.0.0
with:
route: POST /repos/${{ github.repository }}/git/refs
ref: refs/tags/v${{ steps.release-please.outputs.major }}
sha: ${{ steps.release-please.outputs.sha }}
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
-1
View File
@@ -1,4 +1,3 @@
.env
coverage
node_modules/
.DS_Store
+3
View File
@@ -0,0 +1,3 @@
{
".": "3.1.1"
}
-29
View File
@@ -195,28 +195,6 @@ 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: 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]
@@ -378,13 +356,6 @@ 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-<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`).
-3
View File
@@ -21,9 +21,6 @@ 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
+132 -135
View File
@@ -22964,30 +22964,37 @@ 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;
}
if (error2.message === "Load failed") {
return error2.stack === void 0;
const { message, stack } = error2;
if (message === "Load failed") {
return stack === void 0 || "__sentry_captured__" in error2;
}
return errorMessages.has(error2.message);
if (message.startsWith("error sending request for url")) {
return true;
}
if (message === "Failed to fetch" || message.startsWith("Failed to fetch (") && message.endsWith(")")) {
return true;
}
return errorMessages.has(message);
}
// node_modules/p-retry/index.js
@@ -23017,6 +23024,14 @@ 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();
@@ -23044,6 +23059,26 @@ 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) {
@@ -23051,55 +23086,60 @@ 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
retriesConsumed,
retryDelay: effectiveDelay
});
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)) {
if (consumeRetry) {
throw normalizedError;
}
options.signal?.throwIfAborted();
return false;
throw normalizedError;
}
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 delayTime = calculateDelay(retriesConsumed, options);
const finalDelay = Math.min(delayTime, remainingTime);
const finalDelay = Math.min(effectiveDelay, remainingTimeAfterShouldRetry);
options.signal?.throwIfAborted();
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 });
});
}
await delayForRetry(finalDelay, options);
options.signal?.throwIfAborted();
return true;
}
@@ -23119,6 +23159,9 @@ 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 });
@@ -23153,44 +23196,60 @@ async function pRetry(input, options = {}) {
}
// lib/main.js
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");
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("")}`
);
}
const target = resolveInstallationTarget(enterprise, owner, repositories, core);
const auth5 = createAppAuth2({
appId: clientId,
privateKey,
request: request2
});
let authentication, installationId, appSlug;
if (target.type === "enterprise") {
({ authentication, installationId, appSlug } = await pRetry(
() => getTokenFromEnterprise(request2, auth5, target.enterprise, permissions),
{
shouldRetry: ({ error: error2 }) => error2.status >= 500,
onFailedAttempt: (context) => {
core.info(
`Failed to create token for enterprise "${target.enterprise}" (attempt ${context.attemptNumber}): ${context.error.message}`
);
},
retries: 3
}
));
} else if (target.type === "repository") {
if (parsedRepositoryNames.length > 0) {
({ authentication, installationId, appSlug } = await pRetry(
() => getTokenFromRepository(
request2,
auth5,
target.owner,
target.repositories,
parsedOwner,
parsedRepositoryNames,
permissions
),
{
shouldRetry: ({ error: error2 }) => error2.status >= 500,
onFailedAttempt: (context) => {
core.info(
`Failed to create token for "${target.repositories.join(
`Failed to create token for "${parsedRepositoryNames.join(
","
)}" (attempt ${context.attemptNumber}): ${context.error.message}`
);
@@ -23200,11 +23259,11 @@ async function main(clientId, privateKey, enterprise, owner, repositories, permi
));
} else {
({ authentication, installationId, appSlug } = await pRetry(
() => getTokenFromOwner(request2, auth5, target.owner, permissions),
() => getTokenFromOwner(request2, auth5, parsedOwner, permissions),
{
onFailedAttempt: (context) => {
core.info(
`Failed to create token for "${target.owner}" (attempt ${context.attemptNumber}): ${context.error.message}`
`Failed to create token for "${parsedOwner}" (attempt ${context.attemptNumber}): ${context.error.message}`
);
},
retries: 3
@@ -23220,60 +23279,6 @@ async function main(clientId, privateKey, enterprise, owner, repositories, permi
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
};
}
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,
@@ -23281,7 +23286,14 @@ async function getTokenFromOwner(request2, auth5, parsedOwner, permissions) {
hook: auth5.hook
}
});
return createInstallationAuthResult(auth5, response.data, permissions);
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 };
}
async function getTokenFromRepository(request2, auth5, parsedOwner, parsedRepositoryNames, permissions) {
const response = await request2("GET /repos/{owner}/{repo}/installation", {
@@ -23291,28 +23303,15 @@ async function getTokenFromRepository(request2, auth5, parsedOwner, parsedReposi
hook: auth5.hook
}
});
return createInstallationAuthResult(auth5, response.data, permissions, {
repositoryNames: parsedRepositoryNames
const authentication = await auth5({
type: "installation",
installationId: response.data.id,
repositoryNames: parsedRepositoryNames,
permissions
});
}
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);
const installationId = response.data.id;
const appSlug = response.data["app_slug"];
return { authentication, installationId, appSlug };
}
// lib/request.js
@@ -23356,7 +23355,6 @@ 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");
@@ -23364,7 +23362,6 @@ async function run() {
return main(
clientId,
privateKey,
enterprise,
owner,
repositories,
permissions,
+71 -122
View File
@@ -4,7 +4,6 @@ import pRetry from "p-retry";
/**
* @param {string} clientId
* @param {string} privateKey
* @param {string} enterprise
* @param {string} owner
* @param {string[]} repositories
* @param {undefined | Record<string, string>} permissions
@@ -16,21 +15,59 @@ import pRetry from "p-retry";
export async function main(
clientId,
privateKey,
enterprise,
owner,
repositories,
permissions,
core,
createAppAuth,
request,
skipTokenRevoke,
skipTokenRevoke
) {
// 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");
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}).`
);
}
const target = resolveInstallationTarget(enterprise, owner, repositories, core);
// 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 auth = createAppAuth({
appId: clientId,
@@ -39,35 +76,23 @@ export async function main(
});
let authentication, installationId, appSlug;
// If at least one repository is set, get installation ID from that repository
if (target.type === "enterprise") {
({ authentication, installationId, appSlug } = await pRetry(
() => getTokenFromEnterprise(request, auth, target.enterprise, permissions),
{
shouldRetry: ({ error }) => error.status >= 500,
onFailedAttempt: (context) => {
core.info(
`Failed to create token for enterprise "${target.enterprise}" (attempt ${context.attemptNumber}): ${context.error.message}`
);
},
retries: 3,
}
));
} else if (target.type === "repository") {
if (parsedRepositoryNames.length > 0) {
({ authentication, installationId, appSlug } = await pRetry(
() =>
getTokenFromRepository(
request,
auth,
target.owner,
target.repositories,
parsedOwner,
parsedRepositoryNames,
permissions
),
{
shouldRetry: ({ error }) => error.status >= 500,
onFailedAttempt: (context) => {
core.info(
`Failed to create token for "${target.repositories.join(
`Failed to create token for "${parsedRepositoryNames.join(
","
)}" (attempt ${context.attemptNumber}): ${context.error.message}`
);
@@ -78,11 +103,11 @@ export async function main(
} 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, target.owner, permissions),
() => getTokenFromOwner(request, auth, parsedOwner, permissions),
{
onFailedAttempt: (context) => {
core.info(
`Failed to create token for "${target.owner}" (attempt ${context.attemptNumber}): ${context.error.message}`
`Failed to create token for "${parsedOwner}" (attempt ${context.attemptNumber}): ${context.error.message}`
);
},
retries: 3,
@@ -104,76 +129,6 @@ 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,
};
}
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
@@ -184,8 +139,17 @@ async function getTokenFromOwner(request, auth, parsedOwner, permissions) {
},
});
// Get token for all repositories of the given installation
return createInstallationAuthResult(auth, response.data, 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 };
}
async function getTokenFromRepository(
@@ -205,30 +169,15 @@ async function getTokenFromRepository(
});
// Get token for given repositories
return createInstallationAuthResult(auth, response.data, permissions, {
const authentication = await auth({
type: "installation",
installationId: response.data.id,
repositoryNames: parsedRepositoryNames,
permissions,
});
}
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;
}
// Get token for the enterprise installation
return createInstallationAuthResult(auth, response.data, permissions);
const installationId = response.data.id;
const appSlug = response.data["app_slug"];
return { authentication, installationId, appSlug };
}
-2
View File
@@ -23,7 +23,6 @@ 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")
@@ -38,7 +37,6 @@ async function run() {
return main(
clientId,
privateKey,
enterprise,
owner,
repositories,
permissions,
-28
View File
@@ -28,33 +28,5 @@
"open-cli": "^9.0.0",
"undici": "^7.24.6",
"yaml": "^2.8.3"
},
"release": {
"branches": [
"+([0-9]).x",
"main",
{
"name": "beta",
"prerelease": true
}
],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/github",
"@semantic-release/npm",
"semantic-release-plugin-github-breaking-version-tag",
[
"@semantic-release/git",
{
"assets": [
"package.json",
"package-lock.json",
"dist/*"
],
"message": "build(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
]
]
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"packages": {
".": {
"prerelease": true,
"prerelease-type": "beta",
"include-component-in-tag": false,
"release-type": "node",
"versioning": "prerelease"
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"packages": {
".": {
"include-component-in-tag": false,
"release-type": "node"
}
}
}
+4 -20
View File
@@ -11,13 +11,6 @@ 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");
@@ -46,19 +39,10 @@ for (const file of testFiles) {
NODE_USE_ENV_PROXY,
...env
} = process.env;
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 { stderr, stdout } = await execFileAsync("node", [`tests/${file}`], {
env,
});
const trimmedStderr = stderr.replace(/\r?\n$/, "");
const trimmedStdout = stdout.replace(/\r?\n$/, "");
await t.test("stderr", (t) => {
if (trimmedStderr) t.assert.snapshot(trimmedStderr);
-99
View File
@@ -55,105 +55,6 @@ 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://<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.
::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://<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-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://<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-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.
`;
@@ -1,39 +0,0 @@
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" } },
);
});
@@ -1,25 +0,0 @@
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" } }
);
});
@@ -1,15 +0,0 @@
import { DEFAULT_ENV } from "./main.js";
// Verify `main` exits with an error when `enterprise` is used with `owner` input.
try {
// 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");
} catch (error) {
console.error(error.message);
}
@@ -1,15 +0,0 @@
import { DEFAULT_ENV } from "./main.js";
// Verify `main` exits with an error when `enterprise` is used with `repositories` input.
try {
// 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");
} catch (error) {
console.error(error.message);
}
@@ -1,30 +0,0 @@
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" } }
);
});
@@ -1,32 +0,0 @@
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" } }
);
});