Skip to content

fix(github): allow deployments:read so PR cards can show preview URLs - #551

Merged
tlgimenes merged 2 commits into
mainfrom
fix/github-mint-deployments-read
Aug 27, 2026
Merged

fix(github): allow deployments:read so PR cards can show preview URLs#551
tlgimenes merged 2 commits into
mainfrom
fix/github-mint-deployments-read

Conversation

@tlgimenes

@tlgimenes tlgimenes commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Problem

A Studio task-board card whose PR has a deploy preview shows no preview link. Studio resolves previewUrl from four sources in order — a Workers Builds check-run, a commit-status target_url, the deploy bot's PR comment, and finally GET_PREVIEW_DEPLOYMENT — and for a VTEX FastStore WebOps repo the preview is published only as a GitHub Deployment, so that last source is the only one that can find it.

It has been 403ing for every such repo. From production (deco-studio, all pods, repeating hourly):

[task-board] GET_PREVIEW_DEPLOYMENT failed for <org>/<repo>#176 (deployment preview):
error: Not authorized to read deployments for <org>/<repo> (403).
       The token may lack deployments:read.

Root cause is here, in ALLOWED_PERMISSIONS. deployments was not on the mint allowlist, so capPermissions hard-rejected it, Studio's mint ladder (studio#6333) shed it, and every repo-scoped token was minted without it. The affected repo's stored grant is {contents:write, metadata:read, pull_requests:write, issues:write, checks:read} — no deployments.

This is not one org's misconfiguration. Across the whole production DB: 0 of 207 repo-scoped mcp-github connections carry deployments, while 92 carry checks (most recently minted the same day). checks passes the gate and deployments never does — the allowlist is what has been blocking it.

The live tool schema said as much: "Allowed keys: contents, metadata, pull_requests, issues" — already stale, since checks is in fact accepted.

Changes

1. deployments joins ALLOWED_PERMISSIONS. The one-line fix the rest supports.

2. New READ_ONLY_PERMISSIONS (metadata, checks, deployments) — capped to read whatever the caller asks. Neither write is ever needed and both have teeth: checks:write lets a token POST a green check run, and Studio gates PR merges on check status — a forged ship signal. deployments:write lets it write the environment_url the PR panel then renders as a trusted preview link. Capped rather than rejected, matching capPermissions' documented contract and how metadata has always been handled: stored grants are re-capped on every refresh, so throwing would turn a legacy over-broad grant into a hard refresh failure instead of quietly narrowing it.

3. The refresh path's checks-only widening becomes an ordered ladder (OPTIONAL_READ_UPGRADES + buildUpgradeLadder).

This is the part that needed care. GitHub 422s the whole mint when any requested permission exceeds the installation's grant — it does not partially fulfil. So simply adding deployments to the widened set would have 422'd the refresh for every grant that already had checks; and the old code only retried when it had just added checks (addedChecks), so those grants would have fallen straight through to handleMintFailure, which maps 422 → invalid_grantrevoke. That would have broken working connections, not just failed to fix them.

The ladder sheds one optional at a time, newest first, and ends at the grant's stored set:

{…stored, checks:read, deployments:read}   ← rung 0
{…stored, checks:read}                     ← installation has checks, not deployments
{…stored}                                  ← last resort; a 422 HERE really is grant-invalidating

Rungs a grant already satisfies are deduped away, so a fully-upgraded grant still costs exactly one call. Only a 422 advances the ladder — a 5xx/429 outage or a 401/403 from our own App credentials says nothing about the permission set, so it stops immediately rather than multiplying GitHub calls during an outage.

Cost: until an installation approves a newer permission, each refresh burns one extra 422'd mint per un-approved optional (~1/hour per connection). That is the deliberate price of picking the permission up automatically the moment an org approves it, instead of requiring every connection to be re-imported.

4. The tool's permissions description is derived from the allowlist, so it cannot go stale again the way it did when checks was added.

5. README documents the repository permissions the GitHub App must declare, and that adding one is not self-applying — each installation must accept the request.

Safety

Two guards on buildUpgradeLadder, since the stored grant is KV data written by past versions of the code:

  • Empty stored permissions → no rungs. Both ways of expressing "nothing" would escalate: permissions: {} reads as omitted to GitHub (minting every permission the installation holds), and capPermissions({}) returns the default contents:write set. The caller then reports a transient failure and keeps the grant. Not reachable today (GitHub always echoes metadata back at issue time).
  • A stored key the allowlist no longer permits makes capping throw. That must not escape /repo-grant/token as a 500 — it would bypass the transient-vs-permanent mapping the whole refresh path is built around. The widened rungs are skipped and the verbatim rung still re-mints the grant.

Rollout — this PR alone does not fix anything yet

The mint is gated in three places, and all three must line up:

  1. This allowlist — fixed here.
  2. The GitHub App must declare Deployments: Read in its repository permissions. Until it does, GitHub 422s the request and the ladder shrugs it off.
  3. Each installation must accept the permission request. GitHub does not apply a new permission to existing installations; an owner/admin of each account accepts it via Settings → Applications → <App> → Review request.

Until 2 and 3 happen, behaviour is unchanged: the optional is shed and everything keeps working. Once an installation accepts, its existing grants pick deployments up on their next ~1h /repo-grant/token refresh — no re-import, no re-install, no user action. That self-heal is the reason for the ladder rather than a "delete and re-import the connection" runbook.

No change is needed on the Studio side: it already requests deployments:read and already sheds it gracefully.

Testing

bun test in github/124 pass, 0 fail. bunx oxlint github/server clean; tsc reports nothing in server/ (the remaining errors are pre-existing @decocms/runtime type noise in node_modules, present repo-wide and untouched here).

New/changed coverage:

  • capPermissions accepts deployments:read; the test.each that asserted deployments was hard-rejected is inverted (environments and the rest stay rejected).
  • checks/deployments requested as write are capped to read.
  • Every OPTIONAL_READ_UPGRADES entry is itself in ALLOWED_PERMISSIONS — otherwise rung 0 would throw instead of 422.
  • buildUpgradeLadder: ordering, dedup, the fully-upgraded single-rung case, verbatim-stored-set-as-last-rung, and both safety guards.
  • Refresh: the full 3-rung shed; an installation granting checks but not deployments keeps checks (the regression above); a non-422 stops after one attempt and stays 503 without revoking; a 422 on the last rung does revoke.

🤖 Generated with Claude Code


Summary by cubic

Adds deployments:read to the repo-token allowlist so Studio PR cards show deploy-preview URLs for repos that publish previews only as GitHub Deployments (VTEX FastStore WebOps). Previously every repo-scoped token 403'd on the Deployments API because the allowlist rejected deployments.

Upgrade ladder

  • Refresh now tries to widen grants with checks:read and deployments:read, shedding un-approved optionals one at a time — GitHub 422s the whole mint when any requested permission exceeds the installation's grant.
  • Only a 422 advances the ladder; other failures stop after one attempt and stay transient (never revoke).
  • checks and deployments are capped to read-only, and the tool's permission description is now derived from the allowlist so it cannot go stale again.

Rollout

  • The GitHub App must declare Deployments: Read in repository permissions, and each installation's owner/admin must accept the permission request.
  • Once accepted, existing grants pick the permission up on their next ~1h /repo-grant/token refresh — no re-import, re-install, or user action.

Also drops a trailing space in google-analytics-sa/app.json that was failing check:registry on main.

Written for commit acd90e7. Summary will update on new commits.

Review in cubic

tlgimenes and others added 2 commits August 26, 2026 22:00
Studio's PR card resolves `previewUrl` from four sources; for a VTEX FastStore
WebOps repo the preview is published ONLY as a GitHub Deployment, so the only
source that finds it is `GET_PREVIEW_DEPLOYMENT` against the Deployments API.
That call needs `deployments:read`, which `ALLOWED_PERMISSIONS` did not permit —
so `capPermissions` hard-rejected it, Studio's mint ladder shed it, and every
repo-scoped token 403'd on the endpoint:

  Not authorized to read deployments for <owner>/<repo> (403).
  The token may lack deployments:read.

Across production, 0 of 207 repo-scoped connections carry `deployments` (92
carry `checks`, minted as recently as today) — the allowlist, not any single
org's installation, is what has been gating it.

- Add `deployments` to ALLOWED_PERMISSIONS.
- Add READ_ONLY_PERMISSIONS (metadata, checks, deployments): capped to `read`
  whatever is asked. `checks:write` would let a token post a green check run,
  and Studio gates PR merges on check status; `deployments:write` would let it
  write the `environment_url` the PR panel renders as a preview link.
- Generalize the refresh path's checks-only widening into an ordered ladder
  (OPTIONAL_READ_UPGRADES + buildUpgradeLadder). GitHub 422s the WHOLE mint when
  any requested permission exceeds the installation's grant, so asking for both
  optionals at once would have 422'd every grant that already had `checks` — and
  the old code, which only retried when it had just added `checks`, would have
  gone straight to handleMintFailure and REVOKED those still-valid grants. The
  ladder sheds one optional at a time and ends at the grant's stored set.
- Derive the tool's permissions description from the allowlist; the hand-written
  list had already gone stale when `checks` was added.
- README: document the repository permissions the GitHub App must declare and
  that each installation must accept the request before it takes effect.

Existing grants self-heal on their next ~1h `/repo-grant/token` refresh once
their installation approves the permission — no re-import, no re-install.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Unrelated to this branch's github change — `check:registry` has been failing on
`main` since #550: `google-analytics-sa/app.json` carries a trailing space in
`short_description`, but the `registry.json` committed alongside it does not, so
the generator's output has never matched the checked-in artifact.

Fixed at the source rather than by regenerating, so the stray space isn't baked
into the published registry: with this, `bun run check:registry` is clean and
`registry.json` needs no change at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tlgimenes
tlgimenes merged commit cf58e81 into main Aug 27, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant