diff --git a/tooling/README.md b/tooling/README.md index ce944829..d6c68820 100644 --- a/tooling/README.md +++ b/tooling/README.md @@ -40,6 +40,11 @@ package tree, lockfile digest, SDK archive, and builder provenance in no install-time package dependencies. Source manifests on `main` retain the dependencies needed for development. +Pinned Files & Links also publishes `file-pins/v` tags. Its existing +marketplace entry has no subdirectory, so these tags contain the same root package +tree as `plugin/file-pins`. The tag commit records its source as its parent; the +root package does not include the monorepo provenance file. + Published version tags are immutable. A rerun accepts an existing tag only when its plugin contents match; otherwise bump the version. Other plugins keep their existing release process. The remote compatibility test installs the generated diff --git a/tooling/compatibility/plugin-sdk-upgrade.external.test.ts b/tooling/compatibility/plugin-sdk-upgrade.external.test.ts index 0d40be34..8e308fea 100644 --- a/tooling/compatibility/plugin-sdk-upgrade.external.test.ts +++ b/tooling/compatibility/plugin-sdk-upgrade.external.test.ts @@ -362,3 +362,52 @@ it("preserves Prompt Improver's marketplace prefix and package subdirectory", as expect(manifest.bb.app).toBe("./dist/install-app.mjs"); expect(manifest.dependencies).toBeUndefined(); }); + + +it("installs the latest File Pins tag at the marketplace root", async () => { + if (!pluginRepository) throw new Error("BB_PLUGIN_COMPAT_REPOSITORY is required"); + const repository = pluginRepository; + const moduleUrl = pathToFileURL(resolve(repository, "tooling/publish-install-refs.mjs")).href; + const { prepareVersionedRelease, assertVersionTagUnchanged } = await import(moduleUrl); + await run(process.execPath, [resolve(repository, "tooling/build-plugin.mjs"), resolve(repository, "plugins/file-pins")]); + const release = await prepareVersionedRelease("file-pins"); + expect(release.tag).toBe(`file-pins/v${release.plugin.manifest.version}`); + expect(release.subdirectory).toBe(""); + expect(await git(repository, ["rev-parse", `${release.commit}^{tree}`])).toBe(release.packageTree); + expect((await prepareVersionedRelease("file-pins")).commit).toBe(release.commit); + expect(() => assertVersionTagUnchanged(release, release.commit)).not.toThrow(); + expect(() => assertVersionTagUnchanged(release, release.sourceRevision)) + .toThrow(/already exists with different contents/); + + const fixture = await mkdtemp(join(tmpdir(), "file-pins-version-install-")); + let server: Awaited> | undefined; + try { + const remote = join(fixture, "release.git"); + await git(repository, ["init", "--bare", remote]); + await git(remote, ["fetch", "--depth=1", "--no-tags", repository, release.commit]); + await git(remote, ["tag", release.tag, release.commit]); + const previousRevision = "80b3e89e31da407b84e9de8bee777b16eacead44"; + await git(remote, ["fetch", "--depth=1", "--no-tags", "https://github.com/brsbl/bb-plugins.git", previousRevision]); + await git(remote, ["tag", "file-pins/v0.1.8", previousRevision]); + + server = await startTestServer({ appVersion: "0.43.4" }); + server.pluginService.bindSdk({ baseUrl: server.baseUrl }); + await server.pluginService.install(`git:${remote}@semver:file-pins/:^0.1.2`, { kind: "root" }); + const installed = server.pluginService.list().find((entry) => entry.id === "file-pins")!; + expect(installed).toMatchObject({ version: release.plugin.manifest.version, status: "running" }); + expect(getInstalledPluginRegistration(server.db, "file-pins")) + .toMatchObject({ gitResolvedCommit: release.commit }); + const manifest = JSON.parse(await readFile(join(installed.rootDir, "package.json"), "utf8")); + expect(manifest.bb.host).toBe("./dist/install-host.mjs"); + expect(manifest.dependencies).toBeUndefined(); + expect(manifest.devDependencies).toBeUndefined(); + expect(await readFile(join(installed.rootDir, "dist/app.css"), "utf8")) + .toContain("bb-plugin-release-style"); + } finally { + if (server) { + await server.pluginService.stop(); + await server.close(); + } + await rm(fixture, { recursive: true, force: true }); + } +}, 180_000); diff --git a/tooling/publish-install-refs.mjs b/tooling/publish-install-refs.mjs index b2df8c32..9cf83073 100644 --- a/tooling/publish-install-refs.mjs +++ b/tooling/publish-install-refs.mjs @@ -26,9 +26,11 @@ const productionDependencyFields = [ "peerDependencies", ]; // Opt in per plugin so existing release histories are never rewritten. -const versionTaggedPlugins = new Set(["thread-organizer", "context-katamari", "ambient", "server-switcher", "improve-prompt", "desktop"]); +const versionTaggedPlugins = new Set(["thread-organizer", "context-katamari", "ambient", "server-switcher", "improve-prompt", "desktop", "file-pins"]); // Preserve the published prefix when a workspace directory differs from its stable ID. const versionTagPrefixes = { "improve-prompt": "prompt-shaper" }; +// File Pins was published at the repository root, with no marketplace subdirectory. +const versionTagSubdirectories = { "file-pins": "" }; const explicitlyRetiredInstallRefs = Object.freeze([ "plugin/omegacode", "plugin/saved-places", @@ -267,32 +269,38 @@ export async function prepareVersionedRelease(slug) { throw new Error(`${slug}: stable release version required`); } const tag = `${versionTagPrefixes[slug] ?? slug}/v${version}`; + const subdirectory = versionTagSubdirectories[slug] ?? plugin.source; const temporary = await mkdtemp(resolve(tmpdir(), "bb-plugin-tag-")); const indexPath = resolve(temporary, "index"); try { - git(["read-tree", sourceRevision], { env: { GIT_INDEX_FILE: indexPath } }); - const paths = git(["ls-files", "-z", "--", plugin.source], { - env: { GIT_INDEX_FILE: indexPath }, - }); - git(["update-index", "--force-remove", "-z", "--stdin"], { - env: { GIT_INDEX_FILE: indexPath }, input: paths, - }); - git(["read-tree", `--prefix=${plugin.source}/`, packageTree], { - env: { GIT_INDEX_FILE: indexPath }, - }); - const readJson = async (path) => JSON.parse(await readFile(resolve(root, path), "utf8")); - const provenance = { - sourceCommit: sourceRevision, - plugin: slug, - version, - packageTree, - lockfileSha256: createHash("sha256") - .update(await readFile(resolve(root, "package-lock.json"))).digest("hex"), - sdk: await readJson("tooling/vendor/sdk-provenance.json"), - builder: await readJson("tooling/vendor/plugin-build-provenance.json"), - }; - addBlob(indexPath, "plugin-release.json", `${JSON.stringify(provenance, null, 2)}\n`); - const tree = git(["write-tree"], { env: { GIT_INDEX_FILE: indexPath } }); + // Root-shaped tags use the install package unchanged; only nested tags + // need a repository tree and a separate provenance file. + let tree = packageTree; + if (subdirectory) { + git(["read-tree", sourceRevision], { env: { GIT_INDEX_FILE: indexPath } }); + const paths = git(["ls-files", "-z", "--", plugin.source], { + env: { GIT_INDEX_FILE: indexPath }, + }); + git(["update-index", "--force-remove", "-z", "--stdin"], { + env: { GIT_INDEX_FILE: indexPath }, input: paths, + }); + git(["read-tree", `--prefix=${plugin.source}/`, packageTree], { + env: { GIT_INDEX_FILE: indexPath }, + }); + const readJson = async (path) => JSON.parse(await readFile(resolve(root, path), "utf8")); + const provenance = { + sourceCommit: sourceRevision, + plugin: slug, + version, + packageTree, + lockfileSha256: createHash("sha256") + .update(await readFile(resolve(root, "package-lock.json"))).digest("hex"), + sdk: await readJson("tooling/vendor/sdk-provenance.json"), + builder: await readJson("tooling/vendor/plugin-build-provenance.json"), + }; + addBlob(indexPath, "plugin-release.json", `${JSON.stringify(provenance, null, 2)}\n`); + tree = git(["write-tree"], { env: { GIT_INDEX_FILE: indexPath } }); + } const sourceDate = git(["show", "-s", "--format=%cI", sourceRevision]); const commit = git([ "commit-tree", tree, "-p", sourceRevision, @@ -305,7 +313,7 @@ export async function prepareVersionedRelease(slug) { GIT_AUTHOR_DATE: sourceDate, GIT_COMMITTER_DATE: sourceDate, } }); - return { plugin, tag, commit, packageTree, sourceRevision }; + return { plugin, tag, commit, packageTree, sourceRevision, subdirectory }; } finally { await rm(temporary, { recursive: true, force: true }); } @@ -313,7 +321,9 @@ export async function prepareVersionedRelease(slug) { export function assertVersionTagUnchanged(release, existingCommit) { const existingPackageTree = git([ - "rev-parse", `${existingCommit}:${release.plugin.source}`, + "rev-parse", release.subdirectory + ? `${existingCommit}:${release.subdirectory}` + : `${existingCommit}^{tree}`, ], { allowFailure: true }); if (existingPackageTree !== release.packageTree) { throw new Error(`${release.tag} already exists with different contents; bump the plugin version`); @@ -335,7 +345,7 @@ async function publishVersionTag(release, push) { console.log(`${release.tag} ${existingCommit} already published and unchanged`); return; } - await verifyReleaseCommit(release.plugin, release.commit, release.plugin.source); + await verifyReleaseCommit(release.plugin, release.commit, release.subdirectory); if (push) { // A normal tag push cannot overwrite an existing release, including a // concurrent publisher that won the race after the lookup above.