A release of Ekwo OS is one tag carrying the migrations, the country packs and the npm packages together. There is no "down": a database that has run a migration cannot be walked back, so every release moves forward, and the version a database answers with is what a client decides on.
The criteria for 1.0.0 — what the 1.x line promises and the proofs required
before the tag — are in road-to-1.0.md.
What has to move
Each step below fails a test or the build when it is skipped: a release that is half done is worse than one that is not cut.
The schema version. A migration
supabase/migrations/<YYYYMMDDHHMMSS>_schema_version_<x_y_z>.sqlthat redefinesekwo_schema_version()to return the new number, and does nothing else. Bump it when the release adds anything a package reads — a table, a column, a function.tests/cli/schema-version.test.tscarries the numbers written out by hand; move them in the same commit.The package versions, in every workspace manifest:
npm version <x.y.z> --workspaces --include-workspace-root --no-git-tag-version npm install --package-lock-only npm ci # this must pass before anything else is runThen check the dependency ranges between workspaces,
SERVER_VERSIONinpackages/mcp/src/server.tsandpackages/mcp/server.json; tests keep each equal to the manifest.The schema floor — left where it is, unless a migration is breaking.
ekwo.schemaMinin the manifests ofekwo-os,@ekwo-ai/coreand@ekwo-ai/mcp, andSCHEMA_MINin each package'ssrc/schema.ts, is the oldest schema the packages still read. It is not the number of the release, and cutting a release does not move it: a project below the floor is shown nothing but "update", so a floor raised for nothing cuts off every project that has not migrated yet (26 October 2026, a project at 0.19.0 locked out by a release that had only added a column and two functions).- An additive migration — a table, a column, a function, a right
given — leaves the floor. What the packages read or call of it is named
in
SCHEMA_FEATURES(packages/core/src/schema.ts) under the version that introduced it: a read leaves the column out below that version, and a call is either taken the older way or refused by name (schema_too_old_for). - A breaking migration — a table or a column renamed or dropped, a function whose arguments or answer changed, a right taken away that a call relied on — is the only reason to raise it, and then to the version of that migration, never further. Say so in the changelog under Changed, naming the migration (see below).
tests/schema_floor.test.tsis the proof: it builds a database from the tagv<floor>— its migrations, modules and seeds — and runs the reads of this release against it, as a member and over the MCP server. It needs the tags (git fetch --tags); the CI jobschema-floorruns it with the full history. A release that breaks reading a project at the floor fails there, and is fixed inSCHEMA_FEATURESor by a floor raised on purpose.- An additive migration — a table, a column, a function, a right
given — leaves the floor. What the packages read or call of it is named
in
The changelog.
[Unreleased]becomes## [x.y.z] — YYYY-MM-DDunder Keep a Changelog, with a fresh empty[Unreleased]above it. Readgit logagainst it before closing the section. The schema is said in one line under Changed — "The schema is x.y.z (<timestamp>); the floor of the packages stays at a.b.c." — and a floor that rises gets its own entry: the new floor, the migration that is breaking and why, and that an installation runsekwo migratebefore upgrading the packages.The generated files.
npm run docs:schemaandnpm run inventory; the CI compares both with what the generators produce.The checks.
npm run typecheck && npm test && npm run build node scripts/check-no-private-data.mjs node packages/cli/dist/bin.js --version # prints the new numberThe end-to-end run, against a real Supabase project. The test suite runs on PGlite; this run covers what PGlite does not — the published binary over a pooler, PostgREST and its grants, real JWTs, a hosted project's roles and extensions, and a machine key read by PostgREST.
npm run build npm run e2e:supabase # one country; see the variables below npm run e2e:supabase -- --multi-country # two companies in two countriesIt reads everything from the environment and writes no secret anywhere:
EKWO_DB_URL,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,EKWO_E2E_COUNTRY(no default — a default country is a chart nobody chose), and where a pack needs themEKWO_E2E_CHART,EKWO_E2E_LANGUAGE,EKWO_E2E_FISCAL_YEAR_START,EKWO_E2E_VAT_PERIOD. SetEKWO_E2E_PREVIOUS=ekwo-os@<last tag>so the run upgrades an installation, which is what users actually do.EKWO_E2E_LOAD_DOCUMENTSadds the load paths ofload.md.The project has to be empty and one nobody minds losing: the script installs, books and closes a year, and deletes nothing on its own.
--resetempties it so a failed run can be replayed — for a throwaway project and nothing else. It is not in the CI: it costs money, and a shared project would be one two releases install into at once. Run it with several packs, including ones with a non-calendar year and a non-monthly return.The last run: 11 October 2026, for
0.12.2, on a throwaway project ineu-central-1(Postgres 17.11, session pooleraws-1), deleted afterwards,--resetbetween runs. BE (defaultchart,fr), FR (fr), GB (year opening 1 April), EE (et), LU (fr, quarterly), SG, ZA (monthly), AR, CL and PL, each upgraded fromekwo-os@0.12.1on npm — 7 migrations pending, applied byekwo migratein about 21 s: 22 steps each, all green, 141 to 148 s a run. FR moved its pack from 1.17.1 to 1.17.2 on the way. MA (monthly) stopped at the purchase invoice: its pack 0.2.0 puts its standard-rate taxes on the cash basis, and the script has no payment step yet — a fresh install of0.12.1stops at the same step, and the previous run passed only because it upgraded MA from 0.1.0. Then--multi-countrywith FR and ZW,sb_publishable_…as the anon key: 12 steps, all green, 105 s. No load run.The run before, on 11 October 2026, for
0.12.1, on a throwaway project ineu-central-1(Postgres 17.11, session pooleraws-1), deleted afterwards,--resetbetween runs. BE (defaultchart,fr), FR (fr), GB (year opening 1 April), EE (et), LU (fr, quarterly), SG, ZA (monthly), AR, CL, PL and MA (monthly), each upgraded fromekwo-os@0.12.0on npm — 4 migrations pending, applied byekwo migratein about 22 s: 22 steps each, all green, 146 to 163 s a run, the install of0.12.0alone about 70 s of it. PL moved its pack from 0.2.0 to 0.2.1 and MA from 0.1.0 to 0.2.0 on the way. Then--multi-countrywith FR and ZW,sb_publishable_…as the anon key: 12 steps, all green, 106 s. No load run.
Cutting it
Open a pull request with all of the above. The CI job Migrations are additive refuses a published migration that was modified or deleted.
Merge it.
Tag
main, annotated, and push the tag:git tag -a v<x.y.z> -m "Ekwo OS v<x.y.z>" git push origin v<x.y.z>A tag is never moved and never deleted. It starts the Release workflow, see npm below.
Publish a GitHub Release on that tag, with the changelog section as its body.
npm
The libraries are published as @ekwo-ai/* and the command line as ekwo-os
(installed, its binary is ekwo). Publishing comes with the tag, so every
version on npm is a version whose source can be read. The workflow
.github/workflows/release.yml publishes through npm trusted publishing
(GitHub OIDC, no npm token), with a provenance statement linking each
tarball to the commit and the run that built it.
One-time setup, on npmjs.com
For each public package (the list is npm run release:publish; npm run release:check names the ones still missing): open
https://www.npmjs.com/package/<name>/access, add a Trusted Publisher of
type GitHub Actions naming this repository and the workflow file
release.yml, with no environment. A package never published before is published once by hand
(npm login --auth-type=web, then npm run release:publish -- --for-real on
the tag) before its trusted publisher can be set.
Releasing
Pushing the tag runs npm ci, npm run release:check, the typecheck, the build and
npm run release:publish -- --for-real. A version the registry already holds
is skipped, so a run that stopped half way is simply re-run. A package's npm
page then shows Built and signed on GitHub Actions, and
npm audit signatures verifies it.
Dry run, on any branch: Actions → Release → Run workflow with Publish
for real unticked, or locally npm run build && npm run release:publish. A
real publish needs a v* tag; the script refuses one from a pull request, a
dirty tree or an untagged HEAD, and publishes in dependency order.
The MCP registry
@ekwo-ai/mcp is listed on the official MCP registry under ai.ekwo/mcp,
described by packages/mcp/server.json. The registry reads mcpName from the
published manifest, so this step comes after npm. The ai.ekwo namespace
is proved by a DNS TXT record on ekwo.ai. On every release, from
packages/mcp:
mcp-publisher validate
mcp-publisher login dns --domain ekwo.ai --private-key "$KEY"
mcp-publisher publish
mcp-publisher wants the 32-byte Ed25519 seed as 64 hex characters, while the
key is kept as a PKCS#8 PEM file. The seed is the last 32 bytes of the DER
body:
KEY=$(grep -v -- "-----" ~/.config/ekwo/ekwo-mcp-registry.pem | tr -d "\n" | base64 -d | tail -c 32 | xxd -p -c 64)
After a release
[Unreleased] is empty and the next change starts a new section under it. A
published migration is never edited, not even a comment: it has run on
databases nobody here controls, and a correction is a new file.
Names that change before 1.0
Ekwo keeps books in every country its packs cover — value added tax, goods and services tax, sales tax, or none. A few names in the schema, the MCP server and the command line still carry the vocabulary of value added tax, which is one régime among several. They are renamed before 1.0 so that a name says what a thing does in every country. This section is the announcement: nothing below is renamed yet, and each row says what to call once it is.
The policy
- Breaking changes stop before the release candidate. Every rename on this
list ships, alias included, in a minor release of 0.x;
1.0.0-rc.1carries the new names only, and from there the names are stable. - A renamed name keeps answering for at least one minor release. The new
name arrives in a release whose changelog lists the old one under
Deprecated; the old one does the same thing until it is listed under
Removed, at the earliest one minor release later and at the latest before
1.0.0-rc.1. An MCP tool is registered under both names and says in its description which one to call; a SQL function keeps its former name as a wrapper over the new one; a column is kept in step with its successor; a command-line flag is accepted under both spellings. - The fixed assets module set the pattern. Its tools were
assets_*until 0.10.0, answered under both names in 0.10.0, and arefixed_assets_*only from 0.11.0.
The periodic tax return
vat_return computes the periodic return of every country — a VAT return, a
GST return, a sales tax return — from the boxes of the country's form. Its name
becomes tax_return.
| Today | Becomes | Where |
|---|---|---|
vat_return(company, from, to, …) |
tax_return(company, from, to, …) |
SQL function, and the REST call /rpc/vat_return |
vat_return |
tax_return |
MCP tool |
EkwoClient.vatReturn(), type VatReturnRow |
EkwoClient.taxReturn(), TaxReturnRow |
@ekwo-ai/core |
--vat-period <cadence> |
--return-period <cadence>; --filing-period <form>=<cadence> is unchanged |
ekwo init |
refusal unknown_vat_period |
unknown_filing_period |
ekwo init |
The arguments, the result and what the function computes stay as they are.
Tax numbers and tax categories
| Today | Becomes | Where |
|---|---|---|
companies.vat_number, contacts.vat_number |
tax_number |
columns, and the vat_number field of create_contact, search_contacts, update_company_profile, get_company and list_companies |
--vat <number> |
--tax-number <number> |
ekwo contact add, ekwo contact list |
country_defaults.vat_scheme |
tax_number_scheme |
column: the electronic address scheme a party is reached by through its tax number |
taxes.vat_category, tax_templates.vat_category, document_lines.vat_category |
tax_category |
columns: the category code of the e-invoicing standards (UNCL5305) |
document_lines.vat_rate |
tax_rate |
column: the percentage frozen on the line when it is posted |
vatBalance, type DescribedVatBalance |
taxBalance, DescribedTaxBalance |
ekwo pack describe --json, the command line's library |
Former names already superseded
Two columns and one pack field have had a successor since 0.4.0 and are still
kept for older readers. They are removed before 1.0.0-rc.1.
| Today | Read instead |
|---|---|
companies.vat_period |
company_filing_periods, one cadence per declaration of the company |
country_defaults.vat_period_default |
tax_report_templates.period_default, the cadence each form proposes |
defaults.vat_period in a pack's manifest.json |
period_default in the pack's tax_report.json |
Names that stay
Some names belong to one region or one country because the thing they name does. They stay as they are:
ec_sales_listandec_sales_list()produce the recapitulative statement of supplies inside the European Union, a declaration that exists there only;territories.eu_vat_scope,eu_vat_from,eu_vat_to,vat_prefix,eu_vat_scope_of()andvat_prefix_of()describe membership of the European Union's VAT area.generate_fecandfec_lines()write the Fichier des Écritures Comptables, a file format defined by one country, under its own name — as a bank statement reader is calledcamt.053orcoda.