Repository: semantic-release/github
Pull request #897 (2024-08-15 03:44:52)

feat: verify OAuth scopes of classic GitHub PATs
Solves #895 for classic PATs only


Patch:
diff --git a/lib/definitions/errors.js b/lib/definitions/errors.js
index 74fa1ce..3cdbde5 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 a0987c1..39d5d3c 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


Test patch:
diff --git a/test/integration.test.js b/test/integration.test.js
index 68afd4a..2cf098b 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 495c7b1..90c92cd 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`,
     });