publishing a plugin is pushing a tag

Three mechanisms where there should be one. The catalogue was filled by hand, and each host separately
polled twenty-one repositories with git to work out whether a release existed — a second source of
truth for a fact one place already knew. Shipping photos 2026.09.03.02 correctly and watching alpha
not notice for ten minutes is what started this.

The timer. scripts/marketplace-cron.sh, every minute, on the machine that publishes. Safe now only
because the previous commit made the sync read tags instead of working trees; over the old one this
would have published somebody's afternoon on a schedule. Nothing is written unless the release commit
differs from the one already published, so a tick with nothing to do is one query, one fetch per repo
and no writes — about 2.7s. One timestamped summary line per tick regardless, because a fresh timestamp
is the only thing that separates "nothing to publish" from "the timer died"; a named line only for a
plugin that moved.

The badge. `updateable` now compares the tag at HEAD with the tag the catalogue says is newest —
difference, not ordering, since parsing 2026.08.27.01 works right up until somebody tags v2 and then
fails silently. refreshRemote() and its two-minute window are gone with the polling they existed to
paper over. The git rule stays as the fallback for a plugin the store cannot speak for: an unregistered
checkout, or an unreachable store. That case now reads whatever refs the last pull left, which is the
accepted cost of not making every machine poll every repository forever.

fetchCatalogue()'s cache went from five minutes to one. It only drew tiles before; now the wait for a
release to appear would have been the sync's minute plus its five.

Also: the Update button names its destination whenever a release is known, not only on `new-release`.
An untagged checkout reports `untagged` and used to lose the tag with it, so the button said "Update"
and the owner had to guess where it landed — the marketplace knows, even for a checkout that has never
fetched it.

Verified: forced a stale row, the timer republished it inside 60s and logged one line. Badge decisions
checked against the live catalogue for seven plugins, including the two shapes that matter — at a tag
the store agrees with, and untagged with a release known only to the store. 9 release tests pass, 100%
of release.ts covered, four of them new and all against real repositories.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-03 13:13:43 +01:00
co-authored by Claude Opus 5
parent 78e89ebcec
commit 19bbbab907
14 changed files with 360 additions and 119 deletions
+39 -65
View File
@@ -29,18 +29,32 @@ import { join } from 'node:path';
* is that tag, this checkout is at the latest release. If it is a different one, the remote has moved on.
* That holds for any tag names anyone ever chooses, including ones that do not sort.
*
* ── The answer is never awaited on the network ──
* ── Where "the newest tag" comes from: the catalogue, not this machine's refs ──
*
* Every answer comes from refs already on disk. This is called once per plugin on a request that renders
* a list, and twenty-one network round trips would make the plugins screen feel broken.
* Every answer here is local, and this is called once per plugin on a request that renders a list —
* twenty-one network round trips would make the plugins screen feel broken. That left one hard problem:
* refs on disk go stale. Tags arrived at clone and at the fetch `pullPlugin` does, so you learned an
* update existed BY UPDATING. A machine that had not touched a plugin since August compared this
* morning's release against a snapshot from before it existed and said, truthfully and uselessly, that
* it was at the latest.
*
* But refs on disk go stale, and for a long time nothing refreshed them: tags arrived at clone and at
* the fetch `pullPlugin` does, so you only learned an update existed BY UPDATING. A machine that had not
* updated a plugin since August compared this morning's release against a snapshot from before it
* existed and said, truthfully and uselessly, that it was at the latest.
* That was patched with a rate-limited background `git fetch` per checkout, which worked and was the
* wrong shape: every host in the fleet polling twenty-one repositories to re-derive a fact one place
* already knows. The marketplace publishes releases now — see `servers/marketplace/sync.ts` — so the
* newest release is simply asked of the catalogue, which every host already fetches for the store.
*
* So `refreshRemote` runs a fetch in the background and this request answers from what is there now. The
* NEXT read is current. Rate-limited per checkout and run one at a time — see the note on it.
* published tag (catalogue) vs tag at HEAD (this checkout) → differ, so there is an update
*
* DIFFERENCE, not ordering. `2026.08.27.01` parses fine until somebody tags `v2`, and then it fails
* silently. Two strings that are not equal is a comparison that cannot rot.
*
* ── The git rule stays, for a plugin the store cannot speak for ──
*
* A checkout of something unpublished — a plugin being written, one never registered — has no catalogue
* entry, and the fallback below is the old rule against local remote-tracking refs. Those refs are no
* longer refreshed here, so for that case this can miss a tag until something else fetches. That is the
* accepted cost: the alternative was every machine polling every repository forever to cover a case that
* only arises on a developer's own box, where `git fetch` is a thing they were about to run anyway.
*/
export type ReleaseState = {
@@ -53,65 +67,18 @@ export type ReleaseState = {
reason: 'untagged' | 'new-release' | 'at-latest' | 'not-a-checkout';
};
/**
* A background `git fetch --tags`, at most one per checkout per REFRESH_MS.
*
* ── Not awaited, on purpose ──
*
* The caller is rendering a list. Waiting would put the network on the critical path of a page that has
* to feel instant, which is the reason this file never fetched in the first place. The request answers
* from the refs it has; the fetch it starts is for the next one.
*
* The cost of that is one stale view after a release, corrected by a refresh. The alternative — never
* fetching — is a badge that is wrong until somebody updates for an unrelated reason.
*
* ── One at a time ──
*
* Twenty-one plugins would otherwise spawn twenty-one gits at once, every ten minutes, on machines that
* are not all fast. Chained rather than pooled because there is no hurry: nothing is waiting on it.
*
* Failure is silence. A checkout with no remote, an unreachable host, a repository that has moved — none
* of those should surface as anything, because the list still has a true answer without them.
*/
/*
Two minutes, and the number is chosen for how releases are actually watched.
It was ten. The way this gets used is: ship a plugin, open the page, look for the badge — and ten
minutes of that is indistinguishable from the feature not working. A release tagged at 12:42 was
invisible at 12:45 because the last fetch happened at 12:37, which is correct behaviour and no comfort.
The cost is bounded by the page being open: nothing fetches when nobody is looking, and the fetches are
chained, so the worst case is one small round trip per plugin every two minutes while somebody watches
the plugins screen.
*/
const REFRESH_MS = 2 * 60 * 1000;
const fetchedAt = new Map<string, number>();
let queue: Promise<unknown> = Promise.resolve();
export function refreshRemote(dir: string): void {
const last = fetchedAt.get(dir) ?? 0;
if (Date.now() - last < REFRESH_MS) return;
// Stamped BEFORE the fetch runs, so a slow one cannot be started again by the next twenty requests.
fetchedAt.set(dir, Date.now());
queue = queue.then(async () => {
const proc = Bun.spawn(['git', '-C', dir, 'fetch', '--tags', '--quiet', 'origin'], {
stdout: 'ignore',
stderr: 'ignore',
// A host that hangs must not hold the queue: every plugin behind it would stop refreshing too.
signal: AbortSignal.timeout(20_000),
});
await proc.exited.catch(() => {});
});
}
const run = async (dir: string, args: string[]): Promise<string | null> => {
const proc = Bun.spawn(['git', '-C', dir, ...args], { stdout: 'pipe', stderr: 'ignore' });
const out = await new Response(proc.stdout).text();
return (await proc.exited) === 0 ? out.trim() || null : null;
};
export async function releaseState(dir: string): Promise<ReleaseState> {
/**
* @param publishedTag The tag the marketplace says is this plugin's newest release, or null when the
* catalogue has nothing to say — an unregistered plugin, one never tagged, or a store that could not
* be reached. Null falls through to the git rule rather than to a guess.
*/
export async function releaseState(dir: string, publishedTag: string | null = null): Promise<ReleaseState> {
const none = { tagAtHead: null, newestTag: null };
if (!existsSync(join(dir, '.git'))) {
// Copied in by hand or bundled. Nothing to compare, and nothing to pull either — `pullPlugin` says
@@ -119,12 +86,19 @@ export async function releaseState(dir: string): Promise<ReleaseState> {
return { ...none, updateable: false, reason: 'not-a-checkout' };
}
// Started, not awaited: this answer comes from the refs on disk and the fetch is for the next one.
refreshRemote(dir);
// `--exact-match`, so this is "the tag AT this commit", not "the last tag before it". Fails, and
// therefore returns null, when HEAD is between releases — which is the first half of the rule.
const tagAtHead = await run(dir, ['describe', '--exact-match', '--tags', 'HEAD']);
if (publishedTag) {
// Reported even when HEAD is untagged, so the UI can say what pressing Update lands on. The old
// code returned a bare null here and the button read "Update" with nothing after it.
if (!tagAtHead) return { tagAtHead: null, newestTag: publishedTag, updateable: true, reason: 'untagged' };
return publishedTag === tagAtHead
? { tagAtHead, newestTag: publishedTag, updateable: false, reason: 'at-latest' }
: { tagAtHead, newestTag: publishedTag, updateable: true, reason: 'new-release' };
}
if (!tagAtHead) return { ...none, updateable: true, reason: 'untagged' };
// Asked of the REMOTE branch, not of HEAD: a tag pushed after this checkout was made is not reachable