Files
TREK/plugin-sdk/src/cli/unrelease.ts
T
jubnl d110b3bb6d Fix/plugin audit findings (#2031)
* fix(plugins): enforce manifest apiVersion at install and activation

* test(plugins): exercise the absent-apiVersion default

* feat(plugins): generate the core-event catalog into host-facts

* docs(plugins): explain the packing.listBags write-permission binding

* test(plugins): cover apiVersion upload refusal; tidy docblocks and generator header

* fix(plugin-sdk): validate settings options and oauth like the host

* fix(plugin-sdk): model the full manifest shape and stop dropping fields

* fix(plugin-sdk): drop non-string routeProfiles icons like the host

* docs(plugin-sdk): document host limits, oauth contract, and ignored asUserId

* feat(plugin-sdk): enforce daily budgets and meta quotas in the mock host

* feat(plugin-sdk): expose the generated core-event catalog

* feat(plugin-sdk): scaffold trek range >=3.4.0 <5.0.0 ahead of TREK 4.0

* feat(plugin-sdk): scaffold the oauth broker settings for oauth:client plugins

* chore(plugin-sdk): fix stale mirror docs and the colliding permissions export

* docs(plugin-sdk): add the rate-limit string; restore trek docblock; match host meta sizing

* feat(plugin-sdk): raise the scaffold trek floor to 4.0.0

* docs(wiki): bring the plugin pages in line with the current runtime

* docs: bring the workspace CLAUDE.md guides up to date and make them committable

* feat(plugin-sdk): roll back failed publishes, add unrelease, sync the fork, retro-sign updates

* docs(wiki): document the publish rollback, unrelease, fork sync, and retro-signing

* chore(plugin-sdk): 1.6.0
2026-08-24 23:48:36 +02:00

111 lines
4.4 KiB
TypeScript

/**
* trek-plugin unrelease <tag> — undo a release that never made it into the registry.
*
* A failed publish (or a change of heart before the PR merges) leaves up to three things
* behind: the GitHub release, the remote tag, and the local tag. Cleaning them up by hand
* is three commands in the right order; this is that, with one hard safety rail:
*
* A version that IS published in the registry is immutable — the registry pins the
* artifact's sha256, so deleting or re-cutting its release breaks install/update for
* everyone who already has it. unrelease refuses those outright, with no override.
*/
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { readJsonFile } from './json.js';
import { DEFAULT_REGISTRY } from './checks/network.js';
import { plainLog, type LogSink } from './ui.js';
function git(dir: string, args: string[]): string {
return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: 'pipe' }).toString().trim();
}
interface PublishedIndex { plugins?: Array<{ id?: string; versions?: Array<{ version?: string }> }> }
/**
* The published registry index, or null when it cannot be read (offline, mirror down).
* Null is the dangerous answer — the caller must then have explicit consent (--yes).
*/
async function publishedIndex(registry: string): Promise<PublishedIndex | null> {
try {
const r = await fetch(`https://raw.githubusercontent.com/${registry}/main/dist/index.json`, {
headers: { 'User-Agent': 'trek-plugin' },
});
if (!r.ok) return null;
return (await r.json()) as PublishedIndex;
} catch {
return null;
}
}
export async function unrelease(opts: {
dir: string; tag: string; repo: string; registry?: string;
/** Consent to delete when the registry index cannot be verified. */
yes?: boolean;
log?: LogSink;
}): Promise<{ deleted: string[] }> {
const log = opts.log ?? plainLog;
const dir = path.resolve(opts.dir);
const { tag, repo } = opts;
const registry = opts.registry || DEFAULT_REGISTRY;
const manifest = readJsonFile<{ id?: unknown }>(path.join(dir, 'trek-plugin.json'));
const id = String(manifest.id ?? '');
const version = tag.replace(/^v/, '');
const index = await publishedIndex(registry);
if (index === null) {
if (!opts.yes) {
throw new Error(
`could not read the registry index (${registry}) to verify ${id} ${version} is not published.\n` +
`Deleting a PUBLISHED version's release breaks its sha256 pin for everyone who installed it.\n` +
`If you are sure this version never merged into the registry, re-run with --yes.`,
);
}
log(` ! registry unreachable — proceeding on --yes`);
} else {
const mine = index.plugins?.find((p) => p.id === id);
if (mine?.versions?.some((v) => v.version === version)) {
throw new Error(
`${id} ${version} is PUBLISHED in the registry (${registry}) — its release is immutable.\n` +
`The registry pins this artifact's sha256; deleting the release would break install/update\n` +
`for everyone who has it. Ship a new version instead. (There is deliberately no override.)`,
);
}
}
// Delete in blast-radius order: release, remote tag, local tag. Each is best-effort and
// reported, so a partial previous cleanup doesn't stop the rest.
const deleted: string[] = [];
let hasRelease = false;
try { execFileSync('gh', ['release', 'view', tag, '--repo', repo], { stdio: 'pipe' }); hasRelease = true; } catch { /* absent */ }
if (hasRelease) {
execFileSync('gh', ['release', 'delete', tag, '--repo', repo, '--yes'], { stdio: 'pipe' });
deleted.push('release');
log(` ✓ deleted release ${tag} on ${repo}`);
} else {
log(` - no release ${tag} on ${repo}`);
}
let hasRemoteTag = false;
try { hasRemoteTag = git(dir, ['ls-remote', '--tags', 'origin', `refs/tags/${tag}`]) !== ''; } catch { /* no origin — nothing to delete */ }
if (hasRemoteTag) {
git(dir, ['push', 'origin', `:refs/tags/${tag}`]);
deleted.push('remote tag');
log(` ✓ deleted remote tag ${tag}`);
} else {
log(` - no tag ${tag} on origin`);
}
let hasLocalTag = false;
try { git(dir, ['rev-parse', `${tag}^{commit}`]); hasLocalTag = true; } catch { /* absent */ }
if (hasLocalTag) {
git(dir, ['tag', '-d', tag]);
deleted.push('local tag');
log(` ✓ deleted local tag ${tag}`);
} else {
log(` - no local tag ${tag}`);
}
return { deleted };
}