repomatic.github.token module

GitHub token validation, and the probes of what REPOMATIC_PAT can do.

The validators run before a command that depends on the GitHub API, so a user gets a clear error at startup rather than an opaque failure mid-run. The probes read the permissions and the repository scope of the configured REPOMATIC_PAT: lint-repo reports what they find, and setup-guide asks the reader to fix it.

Note

Why REPOMATIC_PAT is needed

GitHub’s GITHUB_TOKEN cannot modify workflow files in .github/. Neither contents: write, actions: write, nor permissions: write-all grant this ability. The only way to push changes to workflow YAML files is through a fine-grained Personal Access Token with the Workflows permission. Without it, GitHub rejects the push:

! [remote rejected] branch_xxx -> branch_xxx (refusing to allow a GitHub App to create or update workflow .github/workflows/my_workflow.yaml without workflows permission)

Events triggered by GITHUB_TOKEN also start no new workflow runs (see GitHub docs), so tag pushes need the PAT to trigger downstream workflows too.

The Settings → Actions → General → Workflow permissions setting has no effect on this limitation: it is a hard security boundary that GitHub enforces whatever the repository settings say.

The permission has to reach the actual git push, not just the gh CLI. Setting GH_TOKEN in a step’s env only authenticates the gh and repomatic API calls of that process. A bare git push (git_ops.force_push_branch, which every pr-sync template goes through) authenticates with the credentials actions/checkout configured for the job, which default to github.token whatever GH_TOKEN says. A job whose diff can touch .github/workflows/ needs token: ${{ secrets.REPOMATIC_PAT || github.token }} on its own checkout step too, or the push is rejected as if the PAT had never been set.

Every job falls back to GITHUB_TOKEN when the PAT is unavailable (secrets.REPOMATIC_PAT || github.token), and then silently fails any operation that needs the workflows permission or that must trigger a workflow.

Token permission mapping:

  • Workflows: PRs that touch .github/workflows/ files.

  • Contents: tag pushes, release publishing, PR branch creation.

  • Pull requests: every PR-creating job.

  • Dependabot alerts: fix-vulnerable-deps reads vulnerability alerts.

  • Issues: the setup guide issue.

  • Administration: reads the Actions settings the setup guide verifies, SHA pinning required and the fork-PR contributor-approval policy. Read-only: repomatic never writes a repository setting.

  • Metadata: required for every fine-grained token API operation.

class repomatic.github.token.PatProbe(field, permission, endpoint, success, not_found='')[source]

Bases: NamedTuple

One fine-grained PAT permission probe.

A read-only API call whose HTTP 403 unambiguously identifies the missing fine-grained permission. Rows live in PAT_PERMISSION_PROBES.

Create new instance of PatProbe(field, permission, endpoint, success, not_found)

field: str

The key of this probe’s result in PatPermissionResults.

permission: str

Fine-grained permission label, as GitHub’s PAT form spells it.

endpoint: str

Read-only probe endpoint, with a {repo} placeholder.

success: str

Message reported when the probe returns a 2xx.

not_found: str

Message template (with {repo}) when the probe 404s.

Empty for probes whose 404 carries no special meaning: those fall through to the generic failure classification.

property name: str

The permission’s name without its access level, like Contents.

run(repo)[source]

Run this probe against repo.

Parameters:

repo (str) – Repository in owner/repo format.

Return type:

CheckResult

Returns:

A passing result, or a failing one that names the cause.

repomatic.github.token.PAT_PERMISSION_PROBES: tuple[PatProbe, ...] = (('administration', 'Administration: Read-only', 'repos/{repo}/actions/permissions', 'Administration: token has access', ''), ('contents', 'Contents: Read and Write', 'repos/{repo}/contents/.github', 'Contents: token has access', ''), ('issues', 'Issues: Read and Write', 'repos/{repo}/issues?per_page=1&state=all', 'Issues: token has access', ''), ('pull_requests', 'Pull requests: Read and Write', 'repos/{repo}/pulls?per_page=1&state=all', 'Pull requests: token has access', ''), ('vulnerability_alerts', 'Dependabot alerts: Read-only', 'repos/{repo}/dependabot/alerts?per_page=1', 'Dependabot alerts: token has access, alerts enabled', 'Vulnerability alerts are not enabled on the repository. Enable them: gh api repos/{repo}/vulnerability-alerts --method PUT'), ('workflows', 'Workflows: Read and Write', 'repos/{repo}/actions/workflows?per_page=1', 'Workflows: token has access', ''))

The PAT permission probes, in report order.

class repomatic.github.token.PatPermissionResults(dict=None, /, **kwargs)[source]

Bases: UserDict[str, CheckResult]

The result of each PAT permission probe, keyed by PatProbe.field.

Built by check_all_pat_permissions(), in the order of PAT_PERMISSION_PROBES.

failed()[source]

Return (field, message) pairs for each failed probe.

Return type:

list[tuple[str, str]]

repomatic.github.token.check_all_pat_permissions(repo)[source]

Run every PAT permission probe against repo.

The single entry point for PAT permission validation. Both lint-repo and setup-guide call it, so a new row of PAT_PERMISSION_PROBES reaches every consumer.

Parameters:

repo (str) – Repository in owner/repo format.

Return type:

PatPermissionResults

Returns:

The result of each probe.

repomatic.github.token.PAT_SCOPE_PROBE = ('administration', 'Administration: Read-only', 'repos/{repo}/actions/permissions', 'Administration: token has access', '')

The permission probe check_pat_repository_scope() sends to other repositories.

The endpoint that reports a token’s own repository selection, GET /installation/repositories, refuses a personal access token. So the check reads what the token is allowed to do. A token gets a 2xx from this endpoint only for a repository it covers with Administration: Read-only. An answer from another repository then proves the token covers it.

No other row of PAT_PERMISSION_PROBES can do that. A public repository answers the contents, issues, pull requests and workflows endpoints to any caller. The Dependabot alerts endpoint answers 404 wherever alerts are off, which says nothing about the token. Read on 2026-10-13: this endpoint answers 401 to a caller with no token, and 403 to an account that does not administer the repository.

The permissions object that GET /repos/{repo} returns cannot serve either: it describes the account, not the token. Checked on 2026-10-20: a token limited to one repository got push: true there for another repository of the same owner.

repomatic.github.token.PAT_SCOPE_PROBE_LIMIT = 10

How many other repositories check_pat_repository_scope() probes.

Each probe is one API call, made on every lint-repo and setup-guide run. The check takes the owner’s most recently pushed repositories: a token shared between repositories serves the active ones, and a token with access to all of them gets an answer from the first one probed.

repomatic.github.token.check_pat_repository_scope(repo)[source]

Check that the PAT has no access to another repository of the same owner.

A fine-grained PAT should name this repository alone under Only select repositories, to follow the principle of least privilege. The check sends PAT_SCOPE_PROBE to the owner’s PAT_SCOPE_PROBE_LIMIT most recently pushed other repositories. The first one that answers fails the check.

Each probe splits three ways:

  • 2xx: the token covers that repository too. Fail, and name it.

  • HTTP 403: the token is refused there. The check passes once a probe was refused and none answered.

  • Anything else (404, 5xx, network): indeterminate for that repository.

Note

Only a 2xx fails the check, so it never reports a token limited to this repository. A pass is narrower than it reads: it covers the probed repositories, and /users/{owner}/repos lists the public ones only. A token is also refused wherever its account has no admin role, whatever the token covers.

Parameters:

repo (str) – Repository in owner/repo format.

Return type:

CheckResult

Returns:

A CheckResult.

repomatic.github.token.validate_gh_token_env()[source]

Check that a GitHub token environment variable is set.

Lookup order: REPOMATIC_PAT > GH_TOKEN > GITHUB_TOKEN, matching run_gh_command.

Raises:

RuntimeError – If no variable is set.

Return type:

None

repomatic.github.token.GITHUB_RATE_LIMIT_URL = 'https://api.github.com/rate_limit'

The GitHub API endpoint validate_gh_api_access() reads.

Any authenticated caller may read it, and reading it costs no rate limit.

repomatic.github.token.validate_gh_api_access()[source]

Smoke-test the GitHub API and return the response headers.

Calls GET https://api.github.com/rate_limit with the token from environment variables, through request(). The headers are what a caller reads: X-OAuth-Scopes is what tells a classic PAT apart from a fine-grained one.

Return type:

dict[str, str]

Returns:

The response headers, keyed by lowercase name.

Raises:

RuntimeError – If the API returns a 4xx/5xx status, or cannot be reached at all (network error, timeout).

repomatic.github.token.validate_classic_pat_scope(required_scope)[source]

Validate that the GitHub token is a classic PAT with the required scope.

Checks:

  1. A GitHub token environment variable is set.

  2. GitHub API is reachable (smoke-test GET).

  3. Token is a classic PAT (has X-OAuth-Scopes header).

  4. Token has the required scope.

Parameters:

required_scope (str) – The OAuth scope to require (like "notifications").

Return type:

list[str]

Returns:

The full list of scopes on the token.

Raises:

RuntimeError – If any check fails.

repomatic.github.token.validate_notifications_token()[source]

Check the token can read the notifications API, and warn when it can do more.

The unsubscribe command needs a classic PAT holding the notifications scope, per validate_classic_pat_scope(). A token holding other scopes works too, and gets a warning: least privilege asks for that scope alone.

Raises:

RuntimeError – If the token is missing, unreadable, fine-grained, or lacks the scope.

Return type:

None