Files
platform/src/servers/plugins/release.ts
T
pastilhasandClaude Opus 5 19bbbab907 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>
2026-09-03 13:13:43 +01:00

119 lines
6.7 KiB
TypeScript

import { existsSync } from 'node:fs';
import { join } from 'node:path';
/**
* Is there anything to update to?
*
* ── The rule, and why it is about TAGS rather than commits ──
*
* Until 2026-08-27 "update" meant `git pull` and re-install, unconditionally, for every plugin. Nothing
* could say whether there was anything to pull, so the button was always offered and usually did nothing
* — and "Update all" was a row of no-ops with a spinner on it.
*
* Tags fix that, and the owner's rule has two halves:
*
* · **A checkout NOT at a tag is always updateable.** It is somewhere between releases — mid-development,
* or on a commit somebody pushed without tagging. There is no claim being made about it, so pulling is
* always reasonable.
* · **A checkout AT a tag is updateable only when a NEWER tag exists.** It is at a release, and a release
* is a claim that this is a finished thing. Offering an update that lands on the same release is how
* you teach someone to ignore the button.
*
* ── Why "newer" is not a version comparison ──
*
* The obvious implementation parses `2026.08.27.01` and compares. It is wrong the first time a plugin is
* tagged `v2` or `1.0.0-rc1`, and wrong SILENTLY — a plugin that never offers an update looks like a
* plugin nobody has released.
*
* So newness is asked of git instead: the newest tag REACHABLE FROM THE REMOTE BRANCH. If the tag at HEAD
* 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.
*
* ── Where "the newest tag" comes from: the catalogue, not this machine's refs ──
*
* 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.
*
* 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.
*
* 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 = {
/** The tag this checkout is exactly at, or null when it is between releases. */
tagAtHead: string | null;
/** The newest tag reachable from the tracked remote branch, as git orders them. */
newestTag: string | null;
updateable: boolean;
/** Why, in words a UI can show without restating the rule. */
reason: 'untagged' | 'new-release' | 'at-latest' | 'not-a-checkout';
};
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;
};
/**
* @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
// the same thing when asked.
return { ...none, updateable: false, reason: 'not-a-checkout' };
}
// `--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
// from HEAD and would be invisible if we described HEAD instead.
const branch = (await run(dir, ['rev-parse', '--abbrev-ref', 'HEAD'])) ?? 'master';
const newestTag =
(await run(dir, ['describe', '--tags', '--abbrev=0', `origin/${branch}`])) ??
(await run(dir, ['describe', '--tags', '--abbrev=0', 'origin/HEAD']));
// No remote tags known yet — a checkout whose origin has never been fetched for tags. Not updateable
// rather than "always updateable": claiming an update exists on no evidence is the worse error.
if (!newestTag) return { tagAtHead, newestTag: null, updateable: false, reason: 'at-latest' };
return newestTag === tagAtHead
? { tagAtHead, newestTag, updateable: false, reason: 'at-latest' }
: { tagAtHead, newestTag, updateable: true, reason: 'new-release' };
}