Versioning & releases
How tool_releases versioning works — what a new release means, what gets pinned, and the COALESCE trap to avoid.
A release is a row in the tool_releases table pointing at one version of your manifest in vendo-templates. Only one release per tool can be active at a time (enforced by a unique partial index). The active release is what new deploys use. Existing deploys are unaffected — they snapshotted the manifest into deployments.manifest when they originally deployed.
What a new release means
Cutting a new release is three things in sequence:
- A new manifest version at
vendo-templates/<slug>/<new-version>.json. BumppreviousVersionsto include the old version. - A release migration in the monorepo that:
- UPDATEs the existing active row to
status='inactive'. - INSERTs a new row pointing at
<new-version>withstatus='active'.
- UPDATEs the existing active row to
- A
supabase db pushin prod to apply the migration.
Once the migration applies, any tenant who clicks Deploy gets the new version. Existing tenants keep running on their pinned snapshot until you explicitly upgrade them (see Rollout & upgrades).
Version-number conventions
Use semver. The conventions Vendo actually cares about:
- Patch (1.0.0 → 1.0.1) — image rebuild, no manifest shape changes, no integration changes. Safe to upgrade tenants en masse.
- Minor (1.0.x → 1.1.0) — manifest shape changes that are additive (new optional
userInputs[], new optional integrations, additional services). Safe with smoke testing. - Major (1.x.x → 2.0.0) — anything that requires a tenant to re-bind, re-enter inputs, or accept new pricing. Treat as a migration; don't auto-upgrade.
Vendo does not enforce semver — but the rollout tooling assumes it. If you ship a breaking change as a patch, the upgrade workflow has no signal to stop.
What tool_releases actually stores
| Column | Where it comes from | Editable by hand |
|---|---|---|
tool_id | Migration | Never (UUID FK to apps_catalog.id) |
version | Migration, then overwritten by sync | Once at insert; sync overwrites |
template_version | Sync action (mirrors version) | Never |
status | Migration | Per release cut (active / inactive) |
wizard_inputs | Sync action (extracted from userInputs[]) | Never |
wizard_layout | Sync action (extracted from wizardLayout) | Never |
requires | Sync action (extracted from integrations[]) | Never |
The FK is tool_id (UUID) → apps_catalog.id, set in the release migration via SELECT id FROM apps_catalog WHERE slug = '...'. The old tool_slug column was dropped in migration 024.
The requires column keeps the legacy name from when integrations were called "requires" — the data is your integrations[] array, copied verbatim by the sync action.
The COALESCE trap
A common pattern in release migrations is to copy fields forward from the previous release:
-- DON'T DO THIS
INSERT INTO tool_releases (tool_id, version, status, requires)
SELECT t.id, '1.0.5', 'active', COALESCE(tr.requires, prev.requires)
FROM apps_catalog t, tool_releases prev
WHERE t.slug = 'my-tool' AND prev.tool_id = t.id;COALESCE is null-aware, not empty-aware. If your new manifest has "integrations": [], the column is []::jsonb, not NULL. COALESCE([]::jsonb, prev.requires) returns []::jsonb — and your release ships with no integration bindings.
Every new deploy on a release with empty requires ships without integration env vars. The tool boots, looks for TELEGRAM_BOT_TOKEN, finds nothing, crashes. Always write requires explicitly from the manifest, never via COALESCE from the previous row.
Pattern that works:
INSERT INTO tool_releases (tool_id, version, status, requires)
SELECT t.id, '1.0.5', 'active', '[{"provider": "telegram", "profile": "webhook_inbound", "cardinality": "per_deployment"}]'::jsonb
FROM apps_catalog t
WHERE t.slug = 'my-tool';…or fetch it from the manifest JSON in the migration, but always source the array from the new version, never from the old one. The sync action will overwrite this with the manifest's integrations[] on its next run — the explicit value here is just to ensure correctness if the sync hasn't run yet.
Pinning behavior
When a tenant clicks Deploy, the deploy worker:
- Reads the active row from
tool_releases. - Fetches
templates/<slug>/<version>.jsonfrom R2. - Copies the manifest into
deployments.manifestat thevalidate_templatestep.
From that point on, the deployment is pinned to that snapshot. Editing the manifest in R2 after deploy has no effect on the running deployment. Cutting a new release has no effect on existing deployments until you upgrade them.
This is intentional — it means a tenant's experience is stable across your releases until you explicitly migrate them.
Listing releases
SELECT version, status, created_at
FROM tool_releases
WHERE tool_id = (SELECT id FROM apps_catalog WHERE slug = 'my-tool')
ORDER BY created_at DESC;You should see at most one active row and any number of inactive rows from previous cuts.
Next: Rollout & upgrades — moving existing tenants to a new release.