Skip to content

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

Closed
harlan-zw wants to merge 11 commits into
mainfrom
feat/github-app-everywhere
Closed

harlan-zw wants to merge 11 commits into
mainfrom
feat/github-app-everywhere

Conversation

@harlan-zw

Copy link
Copy Markdown
Collaborator

Stacked on #498, #494 and #495. This branch merges all three, so merge them first.

🔗 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 instead of failing the batch.
  • The Worker runs 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.

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 and #498 deploy.

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 fix(artifact-delivery): fall back where GitHub denies the read App #498, fix(registry): spend less GitHub quota on the sync and page views #494 and feat(artifact-delivery): fall back to the requester's GitHub token #495, then 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.

A run polls its Resolution once a second, so one slow build spent the
whole guest allowance of 60 requests a minute and failed with
RATE_LIMITED. Polls now draw from their own allowance (ADR-0012).

GitHub answers a secondary rate limit with 403 or 429 and Retry-After.
A 403 failed the run as SOURCE_ACCESS_DENIED, and a 429 threw into the
queue retry ladder. Both now fail as RATE_LIMITED with the named wait,
and a rate-limited tarball no longer falls back to one read per blob.

Every retryable failure now names retryAfterSeconds. The first queue
retry waits 5 seconds instead of 60, inside the CLI's 60 second window.
The hourly registry sync spends GITHUB_TOKEN to zero before each GitHub
reset. On 2026-10-06, 13 runs failed RATE_LIMITED in those minutes.
GitHub counts a personal token's quota per account, so builds now read
with ARTIFACT_GITHUB_TOKEN when it is set, and with GITHUB_TOKEN until then.
GitHub counts a personal token's quota per account. GITHUB_TOKEN is a
personal token, so the registry sync, page views and the account owner's
other tools spend the same bucket that builds need, and runs failed
RATE_LIMITED in the minutes before each hourly reset.

An App installation has its own bucket. Builds now mint an installation
token for the read App and fall back to ARTIFACT_GITHUB_TOKEN, then
GITHUB_TOKEN, with a reported reason when the App gives none.
…uota

When every shared GitHub bucket is spent, a signed-in run still has its
own: the account's GitHub sign-in token reads public Repositories at
that account's quota. The build retries only the refused read with it,
and only for the account that asked.

The requester row lives while the build runs. The build deletes it when
it settles, account deletion removes it, and the daily purge removes one
a dead build left behind.
GITHUB_TOKEN is a personal token, so the registry shares its hourly quota
with every other tool on that account. Measured 2026-10-06: about 1,650 of
1,900 daily sync jobs only advanced the freshness cursor, about 630 /gh
views a day re-asked GitHub for a repository it had already said was
missing, and the owner and commit reads went out anonymous and failed.

The hourly task now reads candidate summaries in GraphQL batches of 100
and records unchanged repositories in place. Sync and page tree reads go
by tree SHA. The repository page caches 404s and tree scans at the edge,
answers 404 for a private repository, and falls back to ungh.cc metadata
when GitHub refuses. Owner and commit reads authenticate through the ETag
client, so a repeat read answers 304 for free. Sync pauses name the bucket.
…oken

# Conflicts:
#	layers/artifact-delivery/server/utils/build.ts
#	layers/artifact-delivery/server/utils/github-source.ts
#	layers/artifact-delivery/server/utils/queue.ts
#	test/unit/skill-run-sweep.test.ts
…ies the App

neondatabase/agent-skills is public and a personal token reads it, but
GitHub answers the read App 403. Every run of its Skills failed
SOURCE_ACCESS_DENIED after builds moved to the App. A read the App was
denied now repeats once with ARTIFACT_GITHUB_TOKEN or GITHUB_TOKEN, and
the denial is reported as an artifact-github-credential event.
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.
@harlan-zw harlan-zw added the harlan-agent-review Pull request triage requires an adversarial Review. Adding this label manually always forces one. label Oct 6, 2026
@harlan-github-agent harlan-github-agent Bot added harlan-agent-running An Agent holds a Task on this issue or pull request right now. and removed harlan-agent-running An Agent holds a Task on this issue or pull request right now. labels Oct 6, 2026
@harlan-github-agent harlan-github-agent Bot added harlan-agent-running An Agent holds a Task on this issue or pull request right now. and removed harlan-agent-running An Agent holds a Task on this issue or pull request right now. labels Oct 7, 2026
@harlan-zw

Copy link
Copy Markdown
Collaborator Author

🤖 An Agent wrote this comment. Replaced by #525, which rebuilds this change on main now that #494, #495 and #498 have merged. Closing this one.

@harlan-zw harlan-zw closed this Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

harlan-agent-review Pull request triage requires an adversarial Review. Adding this label manually always forces one.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant