From be071a27166294c0eba55da7990b2a963a7732c6 Mon Sep 17 00:00:00 2001 From: Andy Edwards Date: Mon, 2 Sep 2024 11:29:46 -0500 Subject: [PATCH] feat: verify OAuth scopes of classic GitHub PATs (#897) * fix: verify OAuth scopes of classic GitHub PATs * fix: make EGHNOPERMISSION error message clearer * chore: add comment about x-oauth-scopes header * test: fix failing test * test: add integration test for no maintain permission --------- Co-authored-by: Olabode Lawal-Shittabey --- lib/definitions/errors.js | 18 +- lib/verify.js | 17 +- test/integration.test.js | 68 +++++++- test/verify.test.js | 336 +++++++++++++++++++++++++++++++++++++- 4 files changed, 420 insertions(+), 19 deletions(-) diff --git a/lib/definitions/errors.js b/lib/definitions/errors.js index 74fa1ce2..3cdbde55 100644 --- a/lib/definitions/errors.js +++ b/lib/definitions/errors.js @@ -178,12 +178,24 @@ If you are using [GitHub Enterprise](https://enterprise.github.com) please make export function EGHNOPERMISSION({ owner, repo }) { return { - message: `The GitHub token doesn't allow to push on the repository ${owner}/${repo}.`, + message: `The GitHub token doesn't allow to push to and maintain the repository ${owner}/${repo}.`, details: `The user associated with the [GitHub token](${linkify( "README.md#github-authentication", - )}) configured in the \`GH_TOKEN\` or \`GITHUB_TOKEN\` environment variable must allows to push to the repository ${owner}/${repo}. + )}) configured in the \`GH_TOKEN\` or \`GITHUB_TOKEN\` environment variable must have permission to push to and maintain the repository ${owner}/${repo}. -Please make sure the GitHub user associated with the token is an [owner](https://help.github.com/articles/permission-levels-for-a-user-account-repository/#owner-access-on-a-repository-owned-by-a-user-account) or a [collaborator](https://help.github.com/articles/permission-levels-for-a-user-account-repository/#collaborator-access-on-a-repository-owned-by-a-user-account) if the repository belong to a user account or has [write permissions](https://help.github.com/articles/managing-team-access-to-an-organization-repository) if the repository [belongs to an organization](https://help.github.com/articles/repository-permission-levels-for-an-organization).`, +Please make sure the GitHub user associated with the token is an [owner](https://help.github.com/articles/permission-levels-for-a-user-account-repository/#owner-access-on-a-repository-owned-by-a-user-account) or a [collaborator](https://help.github.com/articles/permission-levels-for-a-user-account-repository/#collaborator-access-on-a-repository-owned-by-a-user-account) if the repository belongs to a user account or has [write permissions](https://help.github.com/articles/managing-team-access-to-an-organization-repository) if the repository [belongs to an organization](https://help.github.com/articles/repository-permission-levels-for-an-organization).`, + }; +} + +export function EGHNOSCOPE({ scopes }) { + return { + message: `The GitHub token doesn't have the necessary OAuth scopes to write contents, issues, and pull requests.`, + details: `The [GitHub token](${linkify( + "README.md#github-authentication", + )}) configured in the \`GH_TOKEN\` or \`GITHUB_TOKEN\` environment variable must have the correct scopes. +${scopes ? `\nThe token you used has scopes: ${scopes.join(", ")}\n` : ""} +For classic PATs, make sure the token has the \`repo\` scope if the repository is private, or \`public_repo\` scope otherwise. +For fine-grained PATs, make sure the token has the \`content: write\`, \`issues: write\`, and \`pull_requests: write\` scopes on the repository.`, }; } diff --git a/lib/verify.js b/lib/verify.js index a0987c12..39d5d3c2 100644 --- a/lib/verify.js +++ b/lib/verify.js @@ -105,8 +105,21 @@ export default async function verify(pluginConfig, context, { Octokit }) { ); try { const { - data: { permissions, clone_url }, + headers, + data: { private: _private, permissions, clone_url }, } = await octokit.request("GET /repos/{owner}/{repo}", { repo, owner }); + + // GitHub only returns this header if the token is a classic PAT + if (headers?.["x-oauth-scopes"]) { + const scopes = headers["x-oauth-scopes"].split(/\s*,\s*/g); + if ( + !scopes.includes("repo") && + (_private || !scopes.includes("public_repo")) + ) { + errors.push(getError("EGHNOSCOPE", { scopes })); + } + } + // Verify if Repository Name wasn't changed const parsedCloneUrl = parseGithubUrl(clone_url); if ( @@ -122,7 +135,7 @@ export default async function verify(pluginConfig, context, { Octokit }) { // Do not check for permissions in GitHub actions, as the provided token is an installation access token. // octokit.request("GET /repos/{owner}/{repo}", {repo, owner}) does not return the "permissions" key in that case. // But GitHub Actions have all permissions required for @semantic-release/github to work - if (!env.GITHUB_ACTION && !permissions?.push) { + if (!env.GITHUB_ACTION && !(permissions?.push && permissions?.maintain)) { // If authenticated as GitHub App installation, `push` will always be false. // We send another request to check if current authentication is an installation. // Note: we cannot check if the installation has all required permissions, it's diff --git a/test/integration.test.js b/test/integration.test.js index 68afd4a9..2cf098b4 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -29,6 +29,7 @@ test("Verify GitHub auth", async (t) => { .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -49,6 +50,43 @@ test("Verify GitHub auth", async (t) => { t.true(fetch.done()); }); +test("Throws when GitHub user lacks maintain permission", async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GITHUB_TOKEN: "github_token" }; + const options = { + repositoryUrl: `git+https://othertesturl.com/${owner}/${repo}.git`, + }; + + const fetch = fetchMock + .sandbox() + .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { + permissions: { + push: true, + maintain: false, + }, + clone_url: `https://api.github.local/${owner}/${repo}.git`, + }); + + const { + errors: [error], + } = await t.throwsAsync( + t.context.m.verifyConditions( + {}, + { cwd, env, options, logger: t.context.logger }, + { + Octokit: TestOctokit.defaults((options) => ({ + ...options, + request: { ...options.request, fetch }, + })), + }, + ), + ); + + t.is(error.code, "EGHNOPERMISSION"); + t.true(fetch.done()); +}); + test("Verify GitHub auth with publish options", async (t) => { const owner = "test_user"; const repo = "test_repo"; @@ -62,6 +100,7 @@ test("Verify GitHub auth with publish options", async (t) => { .get(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -102,6 +141,7 @@ test("Verify GitHub auth and assets config", async (t) => { .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -208,6 +248,7 @@ test("Publish a release with an array of assets", async (t) => { .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }) @@ -303,6 +344,7 @@ test("Publish a release with release information in assets", async (t) => { .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }) @@ -376,6 +418,7 @@ test("Update a release", async (t) => { .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }) @@ -442,7 +485,10 @@ test("Comment and add labels on PR included in the releases", async (t) => { .get( `https://api.github.local/repos/${owner}/${repo}`, { - permissions: { push: true }, + permissions: { + push: true, + maintain: true, + }, full_name: `${owner}/${repo}`, clone_url: `htttps://api.github.local/${owner}/${repo}.git`, }, @@ -550,7 +596,10 @@ test("Open a new issue with the list of errors", async (t) => { .get( `https://api.github.local/repos/${owner}/${repo}`, { - permissions: { push: true }, + permissions: { + push: true, + maintain: true, + }, full_name: `${owner}/${repo}`, clone_url: `htttps://api.github.local/${owner}/${repo}.git`, }, @@ -645,7 +694,10 @@ test("Verify, release and notify success", async (t) => { .get( `https://api.github.local/repos/${owner}/${repo}`, { - permissions: { push: true }, + permissions: { + push: true, + maintain: true, + }, full_name: `${owner}/${repo}`, clone_url: `htttps://api.github.local/${owner}/${repo}.git`, }, @@ -811,7 +863,10 @@ test("Verify, update release and notify success", async (t) => { .get( `https://api.github.local/repos/${owner}/${repo}`, { - permissions: { push: true }, + permissions: { + push: true, + maintain: true, + }, full_name: `${owner}/${repo}`, clone_url: `htttps://api.github.local/${owner}/${repo}.git`, }, @@ -949,7 +1004,10 @@ test("Verify and notify failure", async (t) => { .get( `https://api.github.local/repos/${owner}/${repo}`, { - permissions: { push: true }, + permissions: { + push: true, + maintain: true, + }, full_name: `${owner}/${repo}`, clone_url: `htttps://api.github.local/${owner}/${repo}.git`, }, diff --git a/test/verify.test.js b/test/verify.test.js index 495c7b1b..90c92cd3 100644 --- a/test/verify.test.js +++ b/test/verify.test.js @@ -15,7 +15,7 @@ test.beforeEach((t) => { t.context.logger = { log: t.context.log, error: t.context.error }; }); -test("Verify package, token and repository access", async (t) => { +test("Verify package, token and repository access for private repo with token scopes: repo", async (t) => { const owner = "test_user"; const repo = "test_repo"; const env = { GH_TOKEN: "github_token" }; @@ -30,10 +30,131 @@ test("Verify package, token and repository access", async (t) => { const fetch = fetchMock .sandbox() .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { - permissions: { - push: true, + headers: { + "x-oauth-scopes": "repo", + }, + body: { + private: true, + permissions: { + push: true, + maintain: true, + }, + clone_url: `https://api.github.local/${owner}/${repo}.git`, + }, + }); + + await t.notThrowsAsync( + verify( + { + proxy, + assets, + successComment, + failTitle, + failComment, + labels, + discussionCategoryName, + }, + { + env, + options: { + repositoryUrl: `git+https://othertesturl.com/${owner}/${repo}.git`, + }, + logger: t.context.logger, + }, + { + Octokit: TestOctokit.defaults((options) => ({ + ...options, + request: { ...options.request, fetch }, + })), + }, + ), + ); + t.true(fetch.done()); +}); + +test("Verify package, token and repository access for public repo with token scopes: repo", async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GH_TOKEN: "github_token" }; + const proxy = "https://localhost"; + const assets = [{ path: "lib/file.js" }, "file.js"]; + const successComment = "Test comment"; + const failTitle = "Test title"; + const failComment = "Test comment"; + const labels = ["semantic-release"]; + const discussionCategoryName = "Announcements"; + + const fetch = fetchMock + .sandbox() + .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { + headers: { + "x-oauth-scopes": "repo", + }, + body: { + private: false, + permissions: { + push: true, + maintain: true, + }, + clone_url: `https://api.github.local/${owner}/${repo}.git`, + }, + }); + + await t.notThrowsAsync( + verify( + { + proxy, + assets, + successComment, + failTitle, + failComment, + labels, + discussionCategoryName, + }, + { + env, + options: { + repositoryUrl: `git+https://othertesturl.com/${owner}/${repo}.git`, + }, + logger: t.context.logger, + }, + { + Octokit: TestOctokit.defaults((options) => ({ + ...options, + request: { ...options.request, fetch }, + })), + }, + ), + ); + t.true(fetch.done()); +}); + +test("Verify package, token and repository access for public repo with token scopes: public_repo", async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GH_TOKEN: "github_token" }; + const proxy = "https://localhost"; + const assets = [{ path: "lib/file.js" }, "file.js"]; + const successComment = "Test comment"; + const failTitle = "Test title"; + const failComment = "Test comment"; + const labels = ["semantic-release"]; + const discussionCategoryName = "Announcements"; + + const fetch = fetchMock + .sandbox() + .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { + headers: { + "x-oauth-scopes": "public_repo", + }, + body: { + private: false, + permissions: { + push: true, + maintain: true, + }, + clone_url: `https://api.github.local/${owner}/${repo}.git`, }, - clone_url: `https://api.github.local/${owner}/${repo}.git`, }); await t.notThrowsAsync( @@ -82,6 +203,7 @@ test('Verify package, token and repository access with "proxy", "asset", "discus .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -127,6 +249,7 @@ test("Verify package, token and repository access and custom URL with prefix", a .getOnce(`https://othertesturl.com:9090/prefix/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -168,6 +291,7 @@ test("Verify package, token and repository access and custom URL without prefix" .getOnce(`https://othertesturl.com:9090/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -209,6 +333,7 @@ test("Verify package, token and repository access and shorthand repositoryUrl UR .getOnce(`https://othertesturl.com:9090/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -251,6 +376,7 @@ test("Verify package, token and repository with environment variables", async (t .getOnce(`https://othertesturl.com:443/prefix/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `htttps://api.github.local/${owner}/${repo}.git`, }); @@ -295,6 +421,7 @@ test("Verify package, token and repository access with alternative environment v .getOnce(`https://othertesturl.com:443/prefix/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `htttps://api.github.local/${owner}/${repo}.git`, }); @@ -332,6 +459,7 @@ test("Verify package, token and repository access with custom API URL", async (t .getOnce(`https://api.othertesturl.com:9090/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `htttps://api.github.local/${owner}/${repo}.git`, }); @@ -374,6 +502,7 @@ test("Verify package, token and repository access with API URL in environment va .getOnce(`https://api.othertesturl.com:443/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `htttps://api.github.local/${owner}/${repo}.git`, }); @@ -410,6 +539,7 @@ test('Verify "proxy" is a String', async (t) => { .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -445,6 +575,7 @@ test('Verify "proxy" is an object with "host" and "port" properties', async (t) .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -482,6 +613,7 @@ test('Verify "proxy" is a Boolean set to false', async (t) => { .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -517,6 +649,7 @@ test('Verify "assets" is a String', async (t) => { .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -552,6 +685,7 @@ test('Verify "assets" is an Object with a path property', async (t) => { .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -587,6 +721,7 @@ test('Verify "assets" is an Array of Object with a path property', async (t) => .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -624,6 +759,7 @@ test('Verify "assets" is an Array of glob Arrays', async (t) => { .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -659,6 +795,7 @@ test('Verify "assets" is an Array of Object with a glob Arrays in path property' .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -696,6 +833,7 @@ test('Verify "labels" is a String', async (t) => { .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -731,6 +869,7 @@ test('Verify "assignees" is a String', async (t) => { .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -766,6 +905,7 @@ test('Verify "addReleases" is a valid string (top)', async (t) => { .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -801,6 +941,7 @@ test('Verify "addReleases" is a valid string (bottom)', async (t) => { .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -836,6 +977,7 @@ test('Verify "addReleases" is valid (false)', async (t) => { .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -871,6 +1013,7 @@ test('Verify "draftRelease" is valid (true)', async (t) => { .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -906,6 +1049,7 @@ test('Verify "draftRelease" is valid (false)', async (t) => { .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -1154,7 +1298,99 @@ test("Throw SemanticReleaseError for invalid repositoryUrl", async (t) => { t.is(error.code, "EINVALIDGITHUBURL"); }); -test("Throw SemanticReleaseError if token doesn't have the push permission on the repository and it's not a Github installation token", async (t) => { +test("Throw SemanticReleaseError if token doesn't have the repo or public_repo scope on a public repository", async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GH_TOKEN: "github_token" }; + + const fetch = fetchMock + .sandbox() + .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { + headers: { + "x-oauth-scopes": "repo:status, repo_deployment", + }, + body: { + private: false, + permissions: { + push: true, + maintain: true, + }, + clone_url: `https://api.github.local/${owner}/${repo}.git`, + }, + }); + + const { + errors: [error, ...errors], + } = await t.throwsAsync( + verify( + {}, + { + env, + options: { repositoryUrl: `https://github.com/${owner}/${repo}.git` }, + logger: t.context.logger, + }, + { + Octokit: TestOctokit.defaults((options) => ({ + ...options, + request: { ...options.request, fetch }, + })), + }, + ), + ); + + t.is(errors.length, 0); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EGHNOSCOPE"); + t.true(fetch.done()); +}); + +test("Throw SemanticReleaseError if token doesn't have the repo scope on a private repository", async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GH_TOKEN: "github_token" }; + + const fetch = fetchMock + .sandbox() + .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { + headers: { + "x-oauth-scopes": "repo:status, repo_deployment", + }, + body: { + private: true, + permissions: { + push: true, + maintain: true, + }, + clone_url: `https://api.github.local/${owner}/${repo}.git`, + }, + }); + + const { + errors: [error, ...errors], + } = await t.throwsAsync( + verify( + {}, + { + env, + options: { repositoryUrl: `https://github.com/${owner}/${repo}.git` }, + logger: t.context.logger, + }, + { + Octokit: TestOctokit.defaults((options) => ({ + ...options, + request: { ...options.request, fetch }, + })), + }, + ), + ); + + t.is(errors.length, 0); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EGHNOSCOPE"); + t.true(fetch.done()); +}); + +test("Throw SemanticReleaseError if user doesn't have the push permission on the repository and it's not a Github installation token", async (t) => { const owner = "test_user"; const repo = "test_repo"; const env = { GH_TOKEN: "github_token" }; @@ -1164,6 +1400,7 @@ test("Throw SemanticReleaseError if token doesn't have the push permission on th .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: false, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }) @@ -1197,7 +1434,51 @@ test("Throw SemanticReleaseError if token doesn't have the push permission on th t.true(fetch.done()); }); -test("Do not throw SemanticReleaseError if token doesn't have the push permission but it is a Github installation token", async (t) => { +test("Throw SemanticReleaseError if user doesn't have the maintain permission on the repository and it's not a Github installation token", async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GH_TOKEN: "github_token" }; + + const fetch = fetchMock + .sandbox() + .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { + permissions: { + push: true, + maintain: false, + }, + clone_url: `https://api.github.local/${owner}/${repo}.git`, + }) + .headOnce( + "https://api.github.local/installation/repositories?per_page=1", + 403, + ); + + const { + errors: [error, ...errors], + } = await t.throwsAsync( + verify( + {}, + { + env, + options: { repositoryUrl: `https://github.com/${owner}/${repo}.git` }, + logger: t.context.logger, + }, + { + Octokit: TestOctokit.defaults((options) => ({ + ...options, + request: { ...options.request, fetch }, + })), + }, + ), + ); + + t.is(errors.length, 0); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EGHNOPERMISSION"); + t.true(fetch.done()); +}); + +test("Do not throw SemanticReleaseError if user doesn't have the push permission but it is a Github installation token", async (t) => { const owner = "test_user"; const repo = "test_repo"; const env = { GH_TOKEN: "github_token" }; @@ -1275,7 +1556,7 @@ test(`Don't throw an error if owner/repo only differs in case`, async (t) => { const fetch = fetchMock.sandbox().getOnce( `https://api.github.local/repos/org/foo`, { - permissions: { push: true }, + permissions: { push: true, maintain: true }, clone_url: `https://github.com/ORG/FOO.git`, }, { repeat: 2 }, @@ -1322,7 +1603,10 @@ for (const makeRepositoryUrl of urlFormats) { const fetch = fetchMock.sandbox().getOnce( `https://api.github.local/repos/${owner}/${repo}`, { - permissions: { push: true }, + permissions: { + push: true, + maintain: true, + }, clone_url: make_clone_url(owner, repo), }, { repeat: 2 }, @@ -1357,7 +1641,10 @@ for (const makeRepositoryUrl of urlFormats) { const fetch = fetchMock.sandbox().getOnce( `https://api.github.local/repos/${owner}/${repo}`, { - permissions: { push: true }, + permissions: { + push: true, + maintain: true, + }, clone_url: make_clone_url(owner, repo2), }, { repeat: 2 }, @@ -1491,6 +1778,7 @@ test('Throw SemanticReleaseError if "assets" option is not a String or an Array .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -1531,6 +1819,7 @@ test('Throw SemanticReleaseError if "assets" option is an Array with invalid ele .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -1571,6 +1860,7 @@ test('Throw SemanticReleaseError if "assets" option is an Object missing the "pa .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -1611,6 +1901,7 @@ test('Throw SemanticReleaseError if "assets" option is an Array with objects mis .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -1651,6 +1942,7 @@ test('Throw SemanticReleaseError if "successComment" option is not a String', as .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -1691,6 +1983,7 @@ test('Throw SemanticReleaseError if "successComment" option is an empty String', .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -1731,6 +2024,7 @@ test('Throw SemanticReleaseError if "successComment" option is a whitespace Stri .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -1771,6 +2065,7 @@ test('Throw SemanticReleaseError if "failTitle" option is not a String', async ( .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -1811,6 +2106,7 @@ test('Throw SemanticReleaseError if "failTitle" option is an empty String', asyn .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -1851,6 +2147,7 @@ test('Throw SemanticReleaseError if "failTitle" option is a whitespace String', .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -1891,6 +2188,7 @@ test('Throw SemanticReleaseError if "discussionCategoryName" option is not a Str .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -1931,6 +2229,7 @@ test('Throw SemanticReleaseError if "discussionCategoryName" option is an empty .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -1971,6 +2270,7 @@ test('Throw SemanticReleaseError if "discussionCategoryName" option is a whitesp .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -2011,6 +2311,7 @@ test('Throw SemanticReleaseError if "failComment" option is not a String', async .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -2051,6 +2352,7 @@ test('Throw SemanticReleaseError if "failComment" option is an empty String', as .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -2091,6 +2393,7 @@ test('Throw SemanticReleaseError if "failComment" option is a whitespace String' .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -2131,6 +2434,7 @@ test('Throw SemanticReleaseError if "labels" option is not a String or an Array .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -2171,6 +2475,7 @@ test('Throw SemanticReleaseError if "labels" option is an Array with invalid ele .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -2211,6 +2516,7 @@ test('Throw SemanticReleaseError if "labels" option is a whitespace String', asy .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -2251,6 +2557,7 @@ test('Throw SemanticReleaseError if "assignees" option is not a String or an Arr .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -2291,6 +2598,7 @@ test('Throw SemanticReleaseError if "assignees" option is an Array with invalid .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -2331,6 +2639,7 @@ test('Throw SemanticReleaseError if "assignees" option is a whitespace String', .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -2371,6 +2680,7 @@ test('Throw SemanticReleaseError if "releasedLabels" option is not a String or a .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -2411,6 +2721,7 @@ test('Throw SemanticReleaseError if "releasedLabels" option is an Array with inv .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -2451,6 +2762,7 @@ test('Throw SemanticReleaseError if "releasedLabels" option is a whitespace Stri .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -2491,6 +2803,7 @@ test('Throw SemanticReleaseError if "addReleases" option is not a valid string ( .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -2531,6 +2844,7 @@ test('Throw SemanticReleaseError if "addReleases" option is not a valid string ( .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -2571,6 +2885,7 @@ test('Throw SemanticReleaseError if "addReleases" option is not a valid string ( .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -2611,6 +2926,7 @@ test('Throw SemanticReleaseError if "draftRelease" option is not a valid boolean .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -2650,6 +2966,7 @@ test('Throw SemanticReleaseError if "releaseBodyTemplate" option is an empty str .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, }); @@ -2689,6 +3006,7 @@ test('Throw SemanticReleaseError if "releaseNameTemplate" option is an empty str .getOnce(`https://api.github.local/repos/${owner}/${repo}`, { permissions: { push: true, + maintain: true, }, clone_url: `https://api.github.local/${owner}/${repo}.git`, });