the marketplace publishes releases, not working trees

The sync read `platform/plugins/` — the directories this machine has installed — and imported each
manifest off the working tree. So publishing meant "get officer-dev's checkout to the release, then
run the script", and nothing showed when that had not happened. Live proof at the time of writing:
av-club 2026.08.29.01, kanban 2026.08.29.02, openseo 0.1.0 and fofos-admin 2026.08.29.26 were all in
the store, and none of the four has ever been tagged.

It is also why the job could not go on a timer. A cron over it published whatever was in a working
tree, on a schedule.

Now every registered plugin is mirrored bare into ~/.cache/officer-marketplace/mirrors and read AT ITS
NEWEST TAG — manifest, README and file list all out of `git cat-file`. No working tree in the path, so
publishing something uncommitted stops being a mistake anyone can make.

docs/marketplace.md proposed reading manifests over Gitea's API, which needed a sandbox or a committed
manifest.json. Git gives the same guarantee with neither, and the trust model is unchanged: we import
our own repositories' code. Third-party submissions still need one of those two.

The list of plugins now comes from the `plugins` registry table, which the docs always called the
authoritative half and which was in fact a by-product of what happened to be installed. That is what
lets `finances` be published without being installed here, and it spans orgs — fofos-admin lives under
fofos/, which an org listing would have missed. Adding one is `--add <clone url>`.

A plugin with no tag is left alone and named in the report, not deleted. It has made no release, so
there is nothing true to say about it; dropping its row would be a decision about somebody's plugin
dressed up as a refresh.

Also: `release_tag` on plugin_metadata, because the tag is the fact when a manifest version disagrees
with it; the sync warns on a mismatch. And icon URLs pin to the tag — `/raw/HEAD/` served whatever was
on the default branch, via a 303, so a plugin could show one icon and ship another.

Verified: 21/25 published from a release, 4 named as untagged, all 14 tag-pinned icons answer 200.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-03 13:05:50 +01:00
co-authored by Claude Opus 5
parent 8d39586de5
commit 78e89ebcec
4 changed files with 385 additions and 164 deletions
@@ -14,6 +14,14 @@ export type CatalogueEntry = {
appName: string;
publisher: string;
version: string;
/**
* The git tag this entry was published from.
*
* Optional on the wire, not in the database, and the two mean different things. Here it is absent for
* a row that predates release-based publishing OR for a catalogue too old to send the field at all —
* so a consumer must treat "no tag" as "cannot tell" and fall back to whatever it knew before.
*/
releaseTag?: string;
platform: string;
label: string;
summary: string;
@@ -52,6 +60,7 @@ function toCatalogueEntry(row: Row): CatalogueEntry | null {
appName: row.appName,
publisher: row.developer,
version: m.version,
...(m.releaseTag ? { releaseTag: m.releaseTag } : {}),
platform: m.platform,
label: m.label,
summary: m.summary,
@@ -106,6 +115,22 @@ export async function catalogueEntry(appName: string): Promise<CatalogueEntry |
return { ...entry, ...(row.metadata.readme ? { readme: row.metadata.readme } : {}) };
}
/**
* Every registered plugin — identity and clone URL, no metadata.
*
* This is what the sync iterates, and it is the reason the registry is called authoritative: the
* catalogue lists what somebody deliberately registered, not what happens to be checked out on the
* machine that runs the job. Unlisted rows are included; hiding a plugin from the public API is not the
* same as no longer tracking it, and one taken down for a bad release has to be able to come back
* current.
*/
export async function listRegistry(): Promise<{ id: number; appName: string; repoUrl: string }[]> {
return marketplaceDb
.select({ id: plugins.id, appName: plugins.appName, repoUrl: plugins.repoUrl })
.from(plugins)
.orderBy(plugins.appName);
}
// ── Writes. Used by the sync script, never by the public API ──
type RegisterParams = { appName: string; developer: string; repoUrl: string };
@@ -73,6 +73,19 @@ export const pluginMetadata = pgTable(
.references(() => plugins.id, { onDelete: 'cascade' }),
version: text('version').notNull(),
/**
* The git tag this row was read from — what "published" actually means.
*
* `version` above is the manifest's own claim, and the two are the same string every time somebody
* tags correctly. They are kept apart because when they DISAGREE the tag is the fact: it is what was
* pushed, and a manifest whose version nobody bumped would otherwise make a real release invisible
* to anything comparing versions.
*
* Nullable because rows existed before releases were the unit of publishing. A null one is a
* snapshot taken from a working tree by the old sync, and every consumer should read it as "unknown"
* rather than "no release".
*/
releaseTag: text('release_tag'),
/** The platform range it declares support for, e.g. `>=1.0.0`. */
platform: text('platform').notNull(),
label: text('label').notNull(),