Skip to content

fix(api): stop serving public titles once a repo is made private - #1067

Merged
Zach Dunn (zachdunn) merged 1 commit into
mainfrom
fix/ghref-pub-privatized
Oct 4, 2026
Merged

Zach Dunn (zachdunn) merged 1 commit into
mainfrom
fix/ghref-pub-privatized

Conversation

@zachdunn

Copy link
Copy Markdown
Member

Stacked on #1065 (fix/ghref-title-audience). Review that first; this PR's base is that branch.

In plain terms

Public pages read PR and issue titles from a cache that #1065 adds. If a repo was made private after its titles were cached, those titles kept showing on public pages until they expired: up to 1 hour for open items and 24 hours for closed or merged ones. With this change, when GitHub reports that a repo was made private, its cached public titles stop being served right away.

What it does / what it is not

  • Handles the GitHub repository webhook:
    • privatized: writes "private" to the repo-visibility cache (ghpriv:) and bumps a per-repo public-title epoch (ghpubgen:<repo>). Every existing ghref:pub: entry for that repo stops matching at once.
    • publicized: only writes "public" to the visibility cache. Titles return when the public ladder next resolves them.
    • Other repository actions are ignored.
  • Per-ref invalidation is unchanged. titleCacheKeys still returns both ghref: and ghref:pub:, and issues/pull_request deliveries still delete both.
  • The member ladder (ghref:) is not affected. Making a repo private does not change what the linked workspace's members can see.
  • Operator step needed: the App is not subscribed to repository today. It is not in REQUIRED_WEBHOOK_EVENTS or RECOMMENDED_WEBHOOK_EVENTS, and the docs did not list it. Until someone ticks Repository under the App's Settings → Permissions & events → Subscribe to events, this code receives nothing and titles expire on their TTL as before. The event needs only the Metadata (read) permission, which every App already has, so no installation has to re-approve anything.
  • Not in this PR: adding repository to the github health / uploads github doctor check. The CLI's "recommended" note is written for issue_comment ("enables bot-comment self-healing…"), so listing repository there would print a misleading reason. Fixing that wording is a CLI change and needs a changeset, so it is a separate follow-up.

Technical notes

Why an epoch instead of list({ prefix }). KV listing is paginated and eventually consistent. A delete sweep over it could miss keys or need many round trips inside one webhook. The epoch is a single write, and the result does not depend on how many refs the repo has.

Why the epoch is stamped on the value, not folded into the key. Each public entry stores the epoch it was written under ({ v, e }). A read serves it only when e equals the current ghpubgen:<repo>. Putting the epoch in the key would give the same unreachability, but titleCacheKeys(ref) would then need the current epoch, so it would become async and add a KV read to every issues/pull_request delivery. With the epoch on the value, the key stays ghref:pub:<ref>, so per-ref deletes stay exact and synchronous.

Read cost. The public ladder reads the entry and the epoch in parallel (Promise.all), so there is no extra serial round trip within the ~1.4s withPublicTitleBudget. The epoch read is shared per repo per batch, in the same way as the visibility probe. If the epoch read fails, that ref resolves to null (fail closed).

Details:

  • The epoch value is Date.now(), so concurrent bumps need no read-modify-write.
  • Its TTL is SETTLED_TTL + NEGATIVE_TTL (25h), which outlives every entry written before the bump.
  • When the epoch expires, it reads as "none", and entries written after the bump carry a value. Expiry can therefore cause a miss, never a stale hit.
  • The resolver reads the epoch before the visibility check. If a bump lands mid-resolve, the new entry is written under the old epoch and is never served.
  • Repos that were never bumped keep the old stored shape ({ v }, no e). Existing entries stay valid through deploy.

Why the public ladder does not re-check ghpriv: on a cache hit.

  • A live re-check (GitHub API on ghpriv: expiry) would put GitHub latency and failures on the all-hit path every 10 minutes per repo, and titles would vanish during GitHub blips.
  • A KV-only re-check would add a second read per repo but only help for the 10 minutes ghpriv: lives. It also needs the same privatized delivery the epoch already handles.

Casing. repository events key both ghpriv: and ghpubgen: by the lowercased name, because that is what the title ladder reads (refs arrive lowercased). The existing pull_request/issue_comment write-through still uses full_name as sent. I left that alone because it is outside this change.

Retries. The epoch bump is not wrapped in the best-effort try/catch that the privacy priming uses. If it fails, the queue consumer retries the event. Every step of the event is idempotent.

Remaining gap. KV is eventually consistent, so another location can keep serving the old epoch for up to about 60 seconds after the bump.

Test plan

CI Test/Lint is skipped on stacked PRs, so these are local runs on this branch:

  • Wrote the failing test first: a cached ghref:pub: title was still served after a privatized delivery (3 of the new webhook tests failed before the fix).
  • pnpm test (root, full suite): 410 files passed, 1 skipped; 6427 tests passed, 2 skipped; no unhandled errors.
  • pnpm --filter @uploads/api typecheck
  • npx oxlint on the four changed .ts files: clean
  • pnpm format:check: clean
  • New webhook tests:
    • a privatized delivery hides the repo's cached public titles and leaves other repos' titles alone
    • ghpriv: reads private after privatized and public after publicized
    • other actions and malformed payloads are ignored
    • per-ref invalidation still clears both namespaces after a bump
  • New title tests:
    • unstamped entries are served when the repo has no epoch
    • unstamped and older-epoch entries miss once the epoch is bumped
    • new entries are stamped with the current epoch and served from cache
    • the epoch TTL is longer than 24h
    • the epoch is read once per repo per batch
    • an epoch read failure resolves to null
    • the member ladder is unaffected
  • Operator: subscribe the App to the repository event (Settings → Permissions & events → Subscribe to events → Repository).
  • After deploy, privatize a throwaway repo that has a cached public title, and confirm the title stops showing on its /f/ page.

Closes #1066

@changeset-bot

changeset-bot Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 8c02e01

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are limited based on label configuration.

🏷️ Required labels (at least one) (2)
  • coderabbit:review
  • review
🚫 Excluded labels (none allowed) (1)
  • wip

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: f4b7597f-6c60-4332-a2c3-3d8e10b94273

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
uploads-api 8c02e01 Commit Preview URL

Branch Preview URL
Oct 04 2026, 07:29 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
uploads-web 8c02e01 Commit Preview URL

Branch Preview URL
Oct 04 2026, 07:29 PM

@zachdunn

Copy link
Copy Markdown
Member Author

Operator step done: the GitHub App is now subscribed to the repository event. Until this PR deploys, the deployed webhook handler doesn't act on those deliveries, so nothing changes in the meantime.

Base automatically changed from fix/ghref-title-audience to main October 4, 2026 19:23
Handle the repository webhook's privatized and publicized actions. Both
write the new visibility to the ghpriv: cache. privatized also bumps a
per-repo public-title epoch (ghpubgen:<repo>); public cache entries carry
the epoch they were written under and only match the current one, so the
repo's ghref:pub: titles stop being served at once.

Closes #1066
@zachdunn
Zach Dunn (zachdunn) merged commit 35409dc into main Oct 4, 2026
5 checks passed
@zachdunn
Zach Dunn (zachdunn) deleted the fix/ghref-pub-privatized branch October 4, 2026 20:19
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.

Clear public title cache when a repo is made private

1 participant