Skip to content

feat(github): read GitHub as the App everywhere, tokens as fallback - #525

Merged
harlan-zw merged 3 commits into
mainfrom
feat/github-app-everywhere-v2
Oct 7, 2026
Merged

harlan-zw merged 3 commits into
mainfrom
feat/github-app-everywhere-v2

Conversation

@harlan-zw

@harlan-zw harlan-zw commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Replaces #505, rebuilt on main after #494, #495 and #498 merged.

🔗 Linked issue

Related to #491

❓ Type of change

  • 📖 Documentation
  • 🐞 Bug fix
  • 👌 Enhancement
  • ✨ New feature
  • 🧹 Chore
  • ⚠️ Breaking change

📚 Description

GITHUB_TOKEN is a personal token of the harlan-zw account. GitHub counts a personal token's quota per account, so the registry sync, /gh page views and mention checks spent the same bucket as Harlan's own tools, harlan-github-agent and the CI runners. When it ran out, the site and CI stalled together. #491 moved builds to the read App. This moves every other Worker caller.

  • The registry GitHub client asks one credential for every REST and GraphQL read: the read App installation token. The sync, repository maintenance, owned-repo scans, X mention checks, the size guard, auto-index, /api/repos, /api/orgs, featured and skill-related all go through it. The minting and the token cache move to shared/server/github-app-credential.ts, so the registry and builds share one token per isolate.
  • GITHUB_TOKEN becomes a fallback. It repeats only a read GitHub denied the App: a 403 with quota left and no rate limit message, or a FORBIDDEN GraphQL error. Each use emits a github-credential event with outcome app-denied. A batch summary leaves a denied alias to the per-repository sync, and never repeats a whole batch with the fallback.
  • REST, GraphQL and builds share fix(artifact-delivery): fall back where GitHub denies the read App #498's rate limit rule. A 403 whose message names a rate limit or abuse detection stays a limit, and never reaches GITHUB_TOKEN.
  • A 401 on the cached installation token drops it and mints one new token. Only a new token GitHub rejects too reaches the fallback, with an app-token-rejected event. Otherwise a revoked token would send every build read to the fallback, and pause the whole registry sync, until the token's stated expiry.
  • A registry read that GITHUB_TOKEN repeats after a denial reports the App's quota, so one denied Repository never pauses the sync on its owner's bucket. If GITHUB_TOKEN is spent or expired, that read fails as the one Repository's denial.
  • The Worker can run with no GITHUB_TOKEN. A denied registry read then answers denied, which the sync records as a failure for that Repository instead of pausing every Repository for a spent quota. A denied build tries the signed-in requester's token from feat(artifact-delivery): fall back to the requester's GitHub token #495, then answers SOURCE_ACCESS_DENIED with "GitHub denies the skilld.dev read App access to this Repository, and no fallback token is set."
  • Without the App secrets, as in local development, GITHUB_TOKEN stays the primary credential. It also serves every read while the App secrets are unusable or GitHub refuses to mint a token, and its quota then paces the sync.

After #491 deployed, production built 150 runs in 41 minutes as the App, across 68 owners. 3 were denied, all neondatabase/agent-skills, and none was RATE_LIMITED. The app-denied events count denials per day once this deploys.

Callers that still need a credential other than the App:

  • A Repository whose organization denies the App, such as neondatabase. It needs GITHUB_TOKEN, ARTIFACT_GITHUB_TOKEN for builds, or a signed-in requester's own token.
  • Star imports, owned-repository scans and email lookups already use each signed-in account's own token.
  • The daily check reads GITHUB_TOKEN expiry. Without the secret, it reports nothing.
  • Scripts in scripts/ read GITHUB_TOKEN from the local environment. They never run in the Worker.

Every Worker caller reads GitHub through the shared App credential, with GITHUB_TOKEN only after a denial

📝 Migration

  1. Merge this. Let the deploy workflow ship it.
  2. For a day, count denials in Workers Logs for skilld-dev: filter outcome = app-denied and group by operation and github.credential.
  3. Then delete or replace the production GITHUB_TOKEN, from ~/sites/skilld.dev:
    • Delete it, if the denials are few enough to leave failing:
      CLOUDFLARE_ACCOUNT_ID=5904138d55ca25d5670dca6adf99894e pnpm exec wrangler secret delete GITHUB_TOKEN --config wrangler.jsonc
    • Or replace it with a fine-grained token from a GitHub account that runs nothing else, with read access to public repositories only:
      CLOUDFLARE_ACCOUNT_ID=5904138d55ca25d5670dca6adf99894e pnpm exec wrangler secret put GITHUB_TOKEN --config wrangler.jsonc
  4. Revoke the old personal token on GitHub, since production held it.

--config wrangler.jsonc matters: a stray wrangler.toml in the checkout otherwise wins.

🤖 AI disclosure: Harlan Agent Kit modified this description. My AI open-source policy.

GITHUB_TOKEN is a personal token, so the registry sync, page views and
mention checks spent the same bucket as its owner's tools and CI. Every
Worker GitHub read now uses the read App installation token, shared
with builds through one isolate cache.

GITHUB_TOKEN is read only for a read GitHub denied the App, and each use
is reported. Without it, a denied read fails for that Repository only:
the sync no longer counts a denial as a spent quota, and a build answers
SOURCE_ACCESS_DENIED unless the signed-in requester's token can read it.
A 401 on the cached installation token sent every read to GITHUB_TOKEN
until the token's stated expiry, and in the registry it paused the whole
sync. The read now drops the token and mints a new one once; only a
denial, or a new token GitHub rejects too, reaches the fallback.

A read GITHUB_TOKEN repeats for a Repository that denies the App now
answers as that Repository's denial when the token is spent or expired,
and reports the App's quota. Its owner's bucket paused every Repository
before.

The REST, GraphQL and build paths share one rate limit message rule, so
an abuse detection limit, or a FORBIDDEN error that names a limit, never
reaches GITHUB_TOKEN. A summary batch never falls back as a whole.
The runbook and two comments still named GITHUB_TOKEN as the sync's bucket. The App's installation bucket paces the sync now, and a rejected token is renewed before the fallback.
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