troubleshooting warning ci-cd · ·

Fix "Resource not accessible by integration" in GitHub Actions

Your GitHub Actions job fails with 'Resource not accessible by integration' because GITHUB_TOKEN is read-only by default. Grant the exact scope it needs.

GitHub Actions Resource not accessible by integration
Fix "Resource not accessible by integration" in GitHub Actions

Your workflow logs a red Error: Resource not accessible by integration and the job dies the moment it tries to write something back: a label, a comment, a commit, a release. The cause is almost always the same. The GITHUB_TOKEN your job runs with is read-only, so any API call that mutates the repository gets a 403. The fix is to grant that token the specific scope the failing step needs, using the permissions key in your workflow. There are three other situations where that fix alone is not enough, and knowing which one you are in saves you an hour of guessing.

What the error actually means

Every workflow run gets a short-lived GITHUB_TOKEN, generated per job and revoked when the job finishes. It authenticates as a bot identity (github-actions[bot]) against the GitHub API. When a step calls the API to change repository state and the token lacks the matching permission, the API answers 403 Resource not accessible by integration. The word “integration” is GitHub API language for the app behind the token, not a hint that some external integration is misconfigured.

So the message is really saying: this token is not allowed to do that. Two things decide what it is allowed to do: the repository or organization default, and any permissions block in your workflow.

Cause 1: the default token is read-only

Since 2023, new repositories default GITHUB_TOKEN to read-only. Many organizations also flip existing repos to read-only as a hardening step, which is the right call. You can confirm the setting under Settings, Actions, General, Workflow permissions. If it says “Read repository contents and packages permissions”, the default token cannot write anything.

Do not fix this at the repository level by switching the default back to read/write. That grants every workflow in the repo broad access it does not need. Instead, grant the scope in the one workflow that needs it:

permissions:
  contents: write        # push commits or tags
  pull-requests: write   # comment on or label PRs

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/tag-release.sh

One rule trips people up constantly. The moment you add a permissions block, GitHub switches that scope from permissive defaults to explicit mode: every permission you do not list becomes none. If your job also needs to read packages or write to the Checks API, you have to list those too. A job that reads and writes typically needs a handful of scopes spelled out, not one.

You can also scope permissions per job, which is stricter and what I reach for by default. A build job gets contents: read, and only the publish job gets contents: write:

jobs:
  build:
    permissions:
      contents: read
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: make build

  publish:
    needs: build
    permissions:
      contents: write
      packages: write
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: make publish

The full list of scopes and their defaults lives in the GitHub Docs on automatic token authentication. Keep least privilege in mind here; over-scoping the token is one of the quiet ways CI becomes a security liability, a theme covered in GitHub Actions Security: How to Stop Secret Leaks in CI/CD.

Cause 2: the pull request came from a fork

This one catches teams with public repos. When a pull_request event fires from a forked repository, GitHub deliberately hands the workflow a read-only GITHUB_TOKEN and withholds secrets, no matter what your permissions block says. An attacker could otherwise open a PR that runs arbitrary code with write access to your repo. The read-only downgrade is a security boundary, not a bug, and you cannot override it with the permissions key.

If you need to write back on a fork PR (post a comment, apply a label), use the pull_request_target event instead. It runs in the context of the base repository, so the token can be granted write scopes:

on:
  pull_request_target:
    types: [opened, synchronize]

permissions:
  pull-requests: write

Handle pull_request_target carefully. It runs with repository secrets available, so never check out and execute untrusted PR code inside it. Check out the base branch, or only run trusted logic like labeling. The safer pattern for anything that needs the PR’s build output is a two-workflow split: an untrusted pull_request job that builds and uploads an artifact, and a trusted workflow_run job that downloads it and writes results back.

Cause 3: the PR was opened by Dependabot

Dependabot PRs look like internal PRs, but since March 2021 GitHub treats workflow runs triggered by Dependabot as if they came from a fork. The GITHUB_TOKEN is read-only and repository secrets are unavailable. Since October 2021 those runs do respect the permissions key, so a workflow that labels or auto-merges Dependabot PRs can work, but you still have to know two things.

First, secrets your job expects are missing on a Dependabot event. Reference secrets.DEPENDABOT_* values from the separate Dependabot secrets store, not the Actions secrets store. Second, the token is still fork-grade, so write operations that GitHub blocks for forks stay blocked. GitHub documents the exact event matrix in Troubleshooting Dependabot on GitHub Actions.

A common working shape for auto-approving patch bumps:

permissions:
  contents: write
  pull-requests: write

jobs:
  automerge:
    runs-on: ubuntu-latest
    if: github.actor == 'dependabot[bot]'
    steps:
      - run: gh pr merge --auto --squash "$PR_URL"
        env:
          PR_URL: ${{ github.event.pull_request.html_url }}
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Cause 4: the action creates or approves a PR

Some steps hit a separate switch. Actions that open a pull request or approve one (for example, peter-evans/create-pull-request) need both pull-requests: write on the token and a repository setting that is off by default. Under Settings, Actions, General, look for “Allow GitHub Actions to create and approve pull requests” and enable it. Without that box checked, the API returns the same Resource not accessible by integration even when your permissions block looks correct. This is the one case where the scope is right and the error still fires, so check it early if the token clearly has pull-requests: write.

A diagnosis checklist

Work through this in order. The first match is almost always your fix:

  1. Read the failing step. Which API call 403’d (contents, issues, pull-requests, packages, deployments)? That names the scope you are missing.
  2. Is the trigger a fork pull_request or a Dependabot PR? If yes, the token is read-only by design. Move the write logic to pull_request_target or a workflow_run job (fork) or accept fork-grade limits (Dependabot).
  3. Does the repo default to read-only? Add a permissions block granting only the scope from step 1.
  4. Is the step creating or approving a PR? Enable the repository setting for it.
  5. Re-run and confirm.

To see what the token actually carries at runtime, print the scopes near the top of the failing job:

$ echo "$GITHUB_TOKEN" | gh auth login --with-token
$ gh api rate_limit -i 2>&1 | grep -i 'x-oauth-scopes' || echo "using GITHUB_TOKEN, scopes set by permissions block"

If the workflow is a Terraform or infrastructure pipeline that comments plans back on PRs, the same permission model applies; a full working example lives in How to Automate Terraform Reviews with GitHub Actions. And if you are moving a mutating workflow behind a gate before it touches production, the patterns in Testing in Production: Guide to Progressive Delivery pair well with least-privilege tokens.

Verify the fix

After adding the scope, re-run the job and confirm the failing API call now succeeds. A green run is the real signal, but you can also confirm intent by reading the run’s permissions in the logs: expand the “Set up job” step, and GitHub prints the resolved GITHUB_TOKEN permissions for that job. If the scope you added shows there and the call still 403s, you are in Cause 2, 3, or 4, not a missing scope. That distinction is the whole game with this error: decide whether the token could carry the permission at all, then whether you actually granted it.

Get the next article in your inbox

Practical DevOps tips, tutorials, and guides. No spam, unsubscribe anytime.