Skip to content

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.

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.

Terminal window
bun add --dev @flowbrew/plugin-sdk@^0.6.0
export FLOWBREW_API_KEY='<publishing-workspace-key>'
bunx flowbrew-plugin upload --url https://mcp.flowbrew.app \
--manifest manifest.json --channel candidate > candidate.json

The 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):

Terminal window
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:

Terminal window
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:

Terminal window
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.

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 candidate
on:
workflow_dispatch:
inputs:
version:
description: Unique semantic version greater than current live, if any
required: true
type: string
permissions:
contents: read
concurrency:
group: plugin-publication
cancel-in-progress: false
jobs:
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.json

Download 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.