From 9ddb49d9a96819f454b7f088e34ab7b806e31fb2 Mon Sep 17 00:00:00 2001 From: Andy Edwards Date: Wed, 14 Aug 2024 22:43:35 -0500 Subject: [PATCH 1/5] fix: verify OAuth scopes of classic GitHub PATs --- lib/definitions/errors.js | 18 +- lib/verify.js | 16 +- test/integration.test.js | 31 +++- test/verify.test.js | 334 +++++++++++++++++++++++++++++++++++++- 4 files changed, 381 insertions(+), 18 deletions(-) diff --git a/lib/definitions/errors.js b/lib/definitions/errors.js index 74fa1ce2..a48168ce 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 allow 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 b6ccba95..09b14ae2 100644 --- a/lib/verify.js +++ b/lib/verify.js @@ -105,8 +105,20 @@ 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 }); + + 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 (owner !== parsedCloneUrl.owner || repo !== parsedCloneUrl.repo) { @@ -119,7 +131,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 030a7a54..ab9e5fbe 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`, }); @@ -62,6 +63,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 +104,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 +211,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 +307,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 +381,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 +448,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`, }, @@ -545,7 +554,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`, }, @@ -640,7 +652,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`, }, @@ -801,7 +816,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`, }, @@ -934,7 +952,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 37b3afb6..0f39c925 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" }; @@ -1288,7 +1569,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 }, @@ -1323,7 +1607,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 }, @@ -1457,6 +1744,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`, }); @@ -1497,6 +1785,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`, }); @@ -1537,6 +1826,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`, }); @@ -1577,6 +1867,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`, }); @@ -1617,6 +1908,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`, }); @@ -1657,6 +1949,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`, }); @@ -1697,6 +1990,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`, }); @@ -1737,6 +2031,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`, }); @@ -1777,6 +2072,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`, }); @@ -1817,6 +2113,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`, }); @@ -1857,6 +2154,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`, }); @@ -1897,6 +2195,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`, }); @@ -1937,6 +2236,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`, }); @@ -1977,6 +2277,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`, }); @@ -2017,6 +2318,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`, }); @@ -2057,6 +2359,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`, }); @@ -2097,6 +2400,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`, }); @@ -2137,6 +2441,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`, }); @@ -2177,6 +2482,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`, }); @@ -2217,6 +2523,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`, }); @@ -2257,6 +2564,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`, }); @@ -2297,6 +2605,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`, }); @@ -2337,6 +2646,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`, }); @@ -2377,6 +2687,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`, }); @@ -2417,6 +2728,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`, }); @@ -2457,6 +2769,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`, }); @@ -2497,6 +2810,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`, }); @@ -2537,6 +2851,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`, }); @@ -2577,6 +2892,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`, }); @@ -2616,6 +2932,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`, }); @@ -2655,6 +2972,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`, }); From b3760e2a8bb03911039e188941353d891b2df006 Mon Sep 17 00:00:00 2001 From: Andy Edwards Date: Wed, 14 Aug 2024 22:47:22 -0500 Subject: [PATCH 2/5] fix: make EGHNOPERMISSION error message clearer --- lib/definitions/errors.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/definitions/errors.js b/lib/definitions/errors.js index a48168ce..3cdbde55 100644 --- a/lib/definitions/errors.js +++ b/lib/definitions/errors.js @@ -181,7 +181,7 @@ export function EGHNOPERMISSION({ 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 allow to push to and maintain 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 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).`, }; From e6111bc25a5a9000ce55b44546e64d692cb29035 Mon Sep 17 00:00:00 2001 From: Andy Edwards Date: Wed, 14 Aug 2024 22:49:40 -0500 Subject: [PATCH 3/5] chore: add comment about x-oauth-scopes header --- lib/verify.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/verify.js b/lib/verify.js index 09b14ae2..43452bff 100644 --- a/lib/verify.js +++ b/lib/verify.js @@ -109,6 +109,7 @@ export default async function verify(pluginConfig, context, { Octokit }) { 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 ( From 7c5f8ed7221a8ba6951bb8db481726036e85cffd Mon Sep 17 00:00:00 2001 From: Andy Edwards Date: Tue, 27 Aug 2024 10:52:36 -0500 Subject: [PATCH 4/5] test: fix failing test --- test/verify.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/verify.test.js b/test/verify.test.js index 3b0eccf9..90c92cd3 100644 --- a/test/verify.test.js +++ b/test/verify.test.js @@ -1556,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 }, From f5521226c5736d7b8c56a59d8249fd85a712b6cb Mon Sep 17 00:00:00 2001 From: Andy Edwards Date: Fri, 30 Aug 2024 13:20:29 -0500 Subject: [PATCH 5/5] test: add integration test for no maintain permission --- test/integration.test.js | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/test/integration.test.js b/test/integration.test.js index b57309cc..2cf098b4 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -50,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";