DevOps & CI/CD Bug Report

GitHub Actions "Resource not accessible by integration" when commenting on PRs and pushing tags

After moving repositories into a new GitHub organization, our coverage-comment and release-tag steps failed with 403 Resource not accessible by integration. The GITHUB_TOKEN defaulted to read-only. A scoped permissions block fixed it, and fork PRs needed a different design.

Daniel Tran Daniel Tran @fun_dev · December 10, 2024 · 8 min read · 19 Views
Banner: a GitHub Actions workflow token with read-only scope blocked from writing a pull request comment

In November we moved twelve repositories from a personal account into a new GitHub organization for our internal tooling team. Builds kept passing, but two steps started failing in every repository that used them: the job that posts a test coverage summary as a pull request comment, and the job that creates a version tag after a merge to main.

The coverage step uses actions/github-script@v7 and failed with:

RequestError [HttpError]: Resource not accessible by integration
    at /home/runner/work/_actions/actions/github-script/v7/dist/index.js:9537:21
{
  status: 403,
  response: {
    url: 'https://api.github.com/repos/example-org/ledger-cli/issues/218/comments',
    status: 403,
    headers: {
      'x-accepted-github-permissions': 'issues=write; pull_requests=write',
      ...
    },
    data: {
      message: 'Resource not accessible by integration',
      documentation_url: 'https://docs.github.com/rest/issues/comments#create-an-issue-comment',
      status: '403'
    }
  }
}
Error: Unhandled error: HttpError: Resource not accessible by integration

The tagging step, which runs plain git push, failed differently:

remote: Permission to example-org/ledger-cli.git denied to github-actions[bot].
fatal: unable to access 'https://github.com/example-org/ledger-cli/': The requested URL returned error: 403
Error: Process completed with exit code 128.

Reproducing it

Any workflow that writes with the default token reproduces it in a repository whose default token permissions are read-only:

name: repro
on: pull_request
jobs:
  comment:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/github-script@v7
        with:
          script: |
            await github.rest.issues.createComment({
              ...context.repo,
              issue_number: context.issue.number,
              body: 'hello from CI',
            });

What we ruled out

  • A broken or expired secret. Neither step used a personal token; both used the automatic GITHUB_TOKEN, which is minted fresh for every job.
  • Branch protection on main. Tags are not covered by our branch rules, and the error appeared on the PR comment too, which has nothing to do with branches.
  • A GitHub outage. Other repositories still in the personal account ran the same workflow successfully the same hour.

Root cause

The "Set up job" log of every run prints the token's permissions. In the old repositories:

GITHUB_TOKEN Permissions
  Actions: write
  Contents: write
  Issues: write
  PullRequests: write
  ...

In the new organization:

GITHUB_TOKEN Permissions
  Contents: read
  Metadata: read
  Packages: read

GitHub changed the default for new enterprises, organizations and personal repositories in February 2023: the workflow token now gets read-only access to contents and packages unless the workflow asks for more. The old repositories were created long before that and kept the permissive default; the new organization got the restricted one, and transferred repositories inherit the organization setting. Our workflows never declared a permissions block because they had never needed to.

What is happening under the hood

GITHUB_TOKEN is an installation token for the GitHub Actions app, scoped to the repository that runs the workflow and valid only for the job. Its permissions come from three layers:

  1. The enterprise, organization or repository default ("Read and write" or "Read repository contents and packages permissions"), under Settings, Actions, General, Workflow permissions.
  2. A permissions: block at the workflow level, which replaces the default for all jobs.
  3. A permissions: block at the job level, which replaces the workflow-level one for that job.

When you set permissions explicitly, every scope you do not list becomes none (except metadata, which is always read). So adding pull-requests: write alone also removes contents: read, which breaks actions/checkout in private repositories. That catches people the first time.

"Resource not accessible by integration" is the generic 403 for an app token missing a permission. The x-accepted-github-permissions response header lists the permission sets that would have worked. A PR comment goes through the Issues API, so either issues: write or pull-requests: write is accepted.

There is one more layer that no setting overrides: workflows triggered by pull_request from a fork always get a read-only token and no access to secrets, so untrusted code cannot write to your repository. Dependabot pull requests are treated the same way.

Environment

Component Version / value
Platform GitHub.com, Team plan, new organization created November 2024
Runner ubuntu-24.04 hosted
Actions actions/checkout@v4, actions/github-script@v7
Org setting Workflow permissions: Read repository contents and packages permissions
Triggers pull_request (comment job), push to main (tag job)

Minimal reproduction

The tag job, as it was:

on:
  push:
    branches: [main]
jobs:
  tag:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - run: |
          version=$(jq -r .version package.json)
          git tag "v$version"
          git push origin "v$version"

Who is affected

Organizations and accounts created after the February 2023 change, any organization whose admins switched the default to read-only, repositories transferred into such an organization, and every workflow run from a fork or Dependabot regardless of settings.

The fix

We added explicit, minimal permissions to each workflow instead of flipping the organization default back to read-write. Read-only at the top, and write only on the job that needs it:

name: ci
on: pull_request

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test -- --coverage

  coverage-comment:
    needs: test
    runs-on: ubuntu-24.04
    if: github.event.pull_request.head.repo.full_name == github.repository
    permissions:
      contents: read
      pull-requests: write
    steps:
      - uses: actions/github-script@v7
        with:
          script: |
            await github.rest.issues.createComment({
              ...context.repo,
              issue_number: context.issue.number,
              body: 'Coverage: 87.4% (+0.6)',
            });

The if condition skips the comment for fork PRs, where it would fail anyway.

For tagging:

 jobs:
   tag:
     runs-on: ubuntu-24.04
+    permissions:
+      contents: write
     steps:
       - uses: actions/checkout@v4

actions/checkout stores the job token in the local git config by default, so the later git push uses it with the new contents: write scope.

Fork pull requests

Two of the repositories are public and get outside contributions. For those we split the work into two workflows: the untrusted pull_request run uploads the coverage summary as an artifact, and a workflow_run workflow, which runs in the context of the base repository with a write token, reads it and posts the comment. It never checks out or executes the PR's code.

We deliberately did not use pull_request_target with a checkout of the PR head. That trigger runs with a write token and secrets; combining it with untrusted code is a well-known way to get a repository compromised.

Alternatives and trade-offs

  • Set the organization default back to read and write. One click, fixes everything, and gives every job in every repository write access it mostly does not need. We rejected it.
  • A GitHub App token via actions/create-github-app-token. Needed when the push must trigger other workflows: events created with GITHUB_TOKEN do not start new workflow runs, so a tag pushed with it will not trigger a push: tags release workflow. We switched the tag job to an app token for that reason a week later.
  • A personal access token. Works, but ties automation to one person's account. Avoid.

How to test the fix

Open a pull request from a branch in the same repository and check the "Set up job" log of the comment job:

GITHUB_TOKEN Permissions
  Contents: read
  Metadata: read
  PullRequests: write

Then verify the token from a temporary step with the GitHub CLI preinstalled on hosted runners:

- name: permission check
  env:
    GH_TOKEN: ${{ github.token }}
    PR_NUMBER: ${{ github.event.pull_request.number }}
  run: gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -f body="permission check"

Confirming in production

  • All twelve repositories passed on their next pull request, and coverage comments appeared again.
  • The tag job pushed v2.14.0 on the next merge, and with the app token the release workflow triggered from it.
  • A fork PR to one public repository showed the comment job as skipped, and the workflow_run follow-up posted the comment.

Regression guard

  • actionlint runs on .github/workflows/** in pre-commit.
  • A small check in our shared CI fails any workflow file without a top-level permissions block.
  • The organization default stays read-only, so a new workflow that forgets to ask fails loudly instead of silently getting write access.

Security implications

The restricted default is a security improvement, and the fix keeps it. Every job now has the least privilege it needs, so a compromised third-party action in the test job can read code but cannot push, tag or comment. Keep fork PRs on pull_request, never check out untrusted code in pull_request_target or workflow_run jobs, and pin third-party actions to a full commit SHA.

Prevention checklist

  • Declare permissions at workflow level as read-only and widen per job.
  • Remember that listing any permission sets all unlisted ones to none.
  • Read the "GITHUB_TOKEN Permissions" section of the job log when a 403 appears.
  • Treat fork and Dependabot runs as read-only by design.
  • Use an app token when a push must trigger further workflows.

References

Add a label

#ci #github-actions #github-token #permissions #git
Written by
Daniel Tran @fun_dev

I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.