Test and promote plugin versions
An upload without channel still publishes a live, current version. Set
channel: "candidate" to store a version for testing without changing the
plugin’s current_version_id. Promotion atomically selects that exact version
and marks it live; it does not upload a manifest or deploy a Worker.
Existing plugins are migrated to their most recently uploaded version, using
the same insertion order as before. Existing manifests, endpoints, and instance
pins remain unchanged. There are only two channels: live and candidate.
Deploy a separate Worker for every version
Section titled “Deploy a separate Worker for every version”An endpoint collision is rejected if either the incoming version or the stored version is a candidate. Candidates must use endpoints distinct from every stored version of that plugin, including old versions used by pinned instances and other candidates. Live uploads may reuse endpoints belonging to other live versions, but cannot reuse a candidate’s endpoints until it is promoted or removed. The rule covers brick and trigger register/unregister URLs, including collisions between them. Multiple handlers within one version can share an endpoint. Every stored version string is unique per plugin across both channels; reuploads are rejected even with identical manifest bytes.
The default upload body and live/current behavior remain compatible with existing SDK clients and CI, including pipelines that redeploy successive live versions to one persistent Worker URL. Distinct immutable URLs are recommended for every version so instance pins preserve the intended code; live-to-live endpoint isolation is not enforced.
Use an immutable release stage/name, such as release-12345-1, with its own
workers.dev hostname. A single repeatedly deployed candidate Worker is
insufficient. Changing just a path, query parameter, or DNS alias that still
routes to the same mutable Worker does not isolate execution either. The
platform checks URL collisions; authors are responsible for deployment isolation.
To preserve version pinning, never redeploy a Worker referenced by a stored
version. The platform does not provision or clean up author Workers.
Deploy first, generate the manifest using the deployed URL, then upload. Include every brick endpoint and trigger lifecycle endpoint. Keep required bindings, secrets, storage and trigger state appropriate to that deployment; test workers must not take over production custom domains, routes, or provider registrations. Retain the tested Worker when it becomes live and keep older Workers available for existing instance pins. The shared Worker diagnostic explains why manifest pinning alone cannot preserve code at a mutable URL.
Upload, opt in, test, promote
Section titled “Upload, opt in, test, promote”For ordinary live publication, use the SDK 0.8.0+
flowbrew-plugin publish --manifest <module> --url <brickSlug>=<https-url>
command described in getting started. It accepts static
manifests or builders, deployment URL JSON, and offline dry runs. The candidate
flow below keeps using upload --channel candidate with an already resolved
JSON manifest; the existing commands remain available.
The CLI ships with @flowbrew/plugin-sdk starting with version 0.6.0. Install
it as a development dependency in the plugin repository. The SDK release must
be published before external repositories can install it; the HTTP API below
also works with existing clients.
bun add --dev @flowbrew/plugin-sdk@^0.6.0export FLOWBREW_API_KEY='<publishing-workspace-key>'bunx flowbrew-plugin upload --url https://mcp.flowbrew.app \ --manifest manifest.json --channel candidate > candidate.jsonThe CLI prints the plugin summary as JSON, including id, versionId, version
and channel. Keep this receipt: promotion accepts the opaque versionId, not
the semantic version string. Both live and candidate uploads must have a semantic
version greater than the current live version selected by plugins.current_version_id.
Parked candidates do not raise that floor: after live 1.0.0 and candidate
2.0.0, a live hotfix 1.0.1 is accepted. When there is no current version,
uploads have no ordering check. Exact version strings must still be unique
across all stored versions and channels, including identical retries.
Do not reupload a candidate with channel: "live"; use promotion. Promotion
does not re-check semantic version ordering, even if a later live upload has
overtaken the candidate. Retrying promotion of the current version is safe.
Historical live versions cannot be promoted; demotion is outside this release.
Use the testing workspace’s key to opt in (public plugins are visible across workspaces; a private plugin is visible only in its owning workspace):
export FLOWBREW_API_KEY='<testing-workspace-key>'bunx flowbrew-plugin set-override --url https://mcp.flowbrew.app \ --plugin-id '<candidate.json id>' --version-id '<candidate.json versionId>'list_plugins now returns that workspace’s selected version, including
versionId and channel. Brick discovery, connection type lookup/validation,
and first use of a plugin in a workflow instance use the workspace override,
then the plugin’s current pointer. Existing instance pins take precedence over
both. Start fresh workflow instances when testing. Existing trigger instances
remain pinned to their original trigger definition; create a test instance from
the candidate definition returned by list_plugins to test trigger behavior.
An initial candidate-only plugin stays hidden until the workspace opts in or
the candidate is promoted.
Once tests pass, use the publishing key to promote without redeploying:
export FLOWBREW_API_KEY='<publishing-workspace-key>'bunx flowbrew-plugin promote --url https://mcp.flowbrew.app \ --plugin-id '<candidate.json id>' --version-id '<candidate.json versionId>'Promotion requires ownership for private plugins and plugins:publish:public
permission for public plugins, matching upload authorization. It moves current
and atomically clears every workspace override selecting the promoted version,
so those workspaces follow subsequent live releases. Overrides selecting other
candidates and existing instance pins remain unchanged. To stop testing a
candidate before promotion, clear the testing workspace’s override:
export FLOWBREW_API_KEY='<testing-workspace-key>'bunx flowbrew-plugin clear-override --url https://mcp.flowbrew.app \ --plugin-id '<candidate.json id>'Clearing an override does not alter existing instance pins. Overrides always belong to the authenticated workspace; callers cannot supply another workspace ID.
API and SDK
Section titled “API and SDK”All three routes use Authorization: Bearer <workspace-api-key> and JSON:
| Route | Body | HTTP 200 result |
|---|---|---|
POST /plugins/upload |
Existing flat manifest, optionally with top-level "channel": "candidate" or "live" |
{ "plugin": { "id": "…", "versionId": "…", "channel": "…", … } } |
POST /plugins/promote |
{ "pluginId": "…", "versionId": "…" } |
The same selection |
POST /plugins/override |
{ "pluginId": "…", "versionId": "…" }, or null to clear |
The same selection |
Invalid credentials return 401. Invalid selections, permission failures,
endpoint collisions, and conflicting publications return 400 with { "error": "…" }, following the existing upload API. A concurrent current-pointer change
rejects promotion; inspect current before retrying. channel is publishing
metadata, separate from the immutable manifest and its hash.
import { uploadPluginManifest, promotePluginVersion, setPluginVersionOverride } from "@flowbrew/plugin-sdk";
const candidate = await uploadPluginManifest({ endpoint: `${mcpOrigin}/plugins/upload`, apiKey: publishingKey, manifest, channel: "candidate",});if (!candidate.versionId) throw new Error("Server did not return the candidate version ID");const selection = { pluginId: candidate.id, versionId: candidate.versionId };await setPluginVersionOverride({ endpoint: `${mcpOrigin}/plugins/override`, apiKey: testingKey, ...selection });// Run your tests in fresh instances before executing the following step.await promotePluginVersion({ endpoint: `${mcpOrigin}/plugins/promote`, apiKey: publishingKey, ...selection });// Promotion clears overrides selecting this version; testing workspaces now follow current.MCP exposes the same operations as promote_plugin({ pluginId, versionId }) and
set_plugin_version_override({ pluginId, versionId }) (nullable to clear).
No UI, additional per-instance override, percentage rollout, rollback/demotion,
or automatic cleanup is included.
CI recipe: distinct SST Worker, candidate upload, manual promotion
Section titled “CI recipe: distinct SST Worker, candidate upload, manual promotion”In a plugin repository using this project’s SST/Cloudflare conventions, keep
the Worker stage-scoped and enable its own URL. For example, adapt the existing
sst.config.ts (retain any plugin-specific bindings):
/// <reference path="./.sst/platform/config.d.ts" />export default $config({ app() { return { name: "example-plugin", home: "cloudflare", removal: "retain" }; }, async run() { const worker = new sst.cloudflare.Worker("Plugin", { handler: "src/worker.ts", url: true, // No shared production custom domain or fixed Worker name on release stages. }); return { pluginUrl: worker.url }; },});Commit a scripts/build-manifest.ts that writes a validated manifest using
PLUGIN_URL for every endpoint and PLUGIN_VERSION for its semantic version.
This small version works when all endpoints in the checked-in template use one
Worker; plugins with multiple Workers should generate each corresponding URL:
import { pluginManifestSchema } from "@flowbrew/plugin-sdk";const manifest = pluginManifestSchema.parse(await Bun.file("manifest.template.json").json());const origin = new URL(process.env.PLUGIN_URL!);const relocate = (value: string) => { const old = new URL(value); return new URL(`${old.pathname}${old.search}`, origin).href;};manifest.version = process.env.PLUGIN_VERSION!;for (const brick of manifest.bricks) brick.endpointUrl = relocate(brick.endpointUrl);for (const trigger of manifest.triggers) { trigger.registerUrl = relocate(trigger.registerUrl); trigger.unregisterUrl = relocate(trigger.unregisterUrl);}console.log(JSON.stringify(pluginManifestSchema.parse(manifest)));Candidate publication workflow (the repository must already depend on SST and
SDK 0.6.0+; configure the named secrets and FLOWBREW_MCP_ORIGIN variable):
name: Publish plugin candidateon: workflow_dispatch: inputs: version: description: Unique semantic version greater than current live, if any required: true type: stringpermissions: contents: readconcurrency: group: plugin-publication cancel-in-progress: falsejobs: candidate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 - run: bun install --frozen-lockfile - name: Deploy once to a unique release stage env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_DEFAULT_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_DEFAULT_ACCOUNT_ID }} run: bunx sst deploy --stage "release-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - name: Generate and upload candidate env: PLUGIN_VERSION: ${{ inputs.version }} FLOWBREW_API_KEY: ${{ secrets.FLOWBREW_PUBLISH_API_KEY }} MCP_ORIGIN: ${{ vars.FLOWBREW_MCP_ORIGIN }} run: | export PLUGIN_URL="$(node -p 'JSON.parse(require("fs").readFileSync(".sst/outputs.json", "utf8")).pluginUrl')" bun scripts/build-manifest.ts > manifest.json bunx flowbrew-plugin upload --url "$MCP_ORIGIN" --manifest manifest.json --channel candidate > candidate.json - uses: actions/upload-artifact@v4 with: name: plugin-candidate path: | manifest.json candidate.jsonDownload the receipt, opt the test workspace in, and run the plugin’s acceptance
tests. Then execute the promote command above, locally or
in a separate manually dispatched CI job using the recorded IDs. That job must
not call sst deploy. After a successful upload, continue using the saved
receipt rather than uploading again; changed code or a different Worker URL needs
a new semantic version. Keep the release stage after promotion. This recipe does
not change the platform’s own deployment pipelines or repair existing shared Workers.