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 is forward, and the version a database answers with is what a client decides on.
v0.2.0, on 14 September 2026, is the first published release. 0.1.0 was the
first schema of this repository and was never tagged.
What has to move, and in which order
Nothing below is optional. Each step fails a test or the build when it is skipped, which is deliberate: 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, keeping the comment and ending with the revoke every migration ends with. It is the only thing that migration does.instance.schema_versionfollows on its own: the column default andinit_instance()both call the function, andekwo migratewrites it back onto an installation that already exists.Bump it when the release adds anything a package of this release reads — a table, a column, a function. A release that touches no schema keeps the number and needs no migration.
tests/cli/schema-version.test.tswrites the three numbers out by hand —RELEASE,PREVIOUSandBUMP, the name of the migration that carries nothing else — because a test that asks the code what it says proves nothing. Move them in the same commit.The package versions. Every workspace manifest, the private root included:
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 runCheck the dependency ranges between the workspaces afterwards —
@ekwo-ai/coreon@ekwo-ai/fec, on the three statement readers,@ekwo-ai/camt053,@ekwo-ai/codaand@ekwo-ai/cfonb120, and on the three book readers,@ekwo-ai/trial-balance,@ekwo-ai/journal-itemsand@ekwo-ai/journal-report;ekwo-oson@ekwo-ai/core;@ekwo-ai/mcpon the core and on@ekwo-ai/fec— and the formatting of the manifests, which npm rewrites. The finalnpm installnpm versionruns on its own fails until those ranges name the new number, because a workspace at0.3.0no longer answers a range of^0.2.0and npm goes looking on the registry for a package that is not there.SERVER_VERSIONinpackages/mcp/src/server.tsfollows the manifest, for the reasonSCHEMA_MINdoes: a bundle that ships no manifest still has to say what it is in the MCP handshake. A test keeps the two equal.packages/mcp/server.jsonfollows it too — itsversionand theversionof its npm package — because it is what the MCP registry lists.tests/mcp/surface.test.tskeeps it equal to the manifest, and itsnameequal tomcpName.The schema floor.
ekwo.schemaMinin the manifests ofekwo-os,@ekwo-ai/coreand@ekwo-ai/mcp, and theSCHEMA_MINconstant in each package'ssrc/schema.ts. A test keeps the manifest and the constant equal, and another refuses a floor newer than the schema the release defines.Raise it when the packages of the release read something an older database does not have. It is a floor, not the version: a release that adds nothing a package reads leaves it where it is. The MCP server refuses a database below it by name, and says to run
ekwo migrate.The changelog.
CHANGELOG.mdfollows Keep a Changelog:[Unreleased]becomes## [x.y.z] — YYYY-MM-DD, a fresh empty[Unreleased]goes above it, and a link reference at the bottom points at the release. Group the entries Added / Changed / Removed / Fixed / Security, once each. Readgit logagainst it before you close the section; what shipped without a line is what nobody will find later.The generated documentation.
npm run docs:schema, because the version migration changes a function body anddocs/schema.mdis the output of the migrations.npm run inventoryis in the list below for the same reason:packages/cli/assets/expected-objects.jsoncarries the objects this release defines and the privileges it grants on them, and it travels inside the published package forekwo doctorto read. CI compares both committed files 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 project.
tests/e2e/proves the whole story against PGlite — the same release installed through the CLI and the waysupabase db pushandpsql -fdo, compared row by row, then a company brought from 1.0.0 through an opening balance, two invoices, the VAT return, both financial statements, a close, a re-opening and a close again. PGlite is real Postgres and four things it is not, and they are the four that break a release:- the published binary, over a pooler connection string;
- PostgREST — a function that exists and was never granted to
authenticatedpasses every test in this repository and answers "permission denied" to the first user; - GoTrue, so row level security judged on a real JWT rather than on a session variable a test set;
- the extensions, roles and defaults a hosted project has;
- the
authenticatorrole andpgrst.db_pre_request, so a machine key sent inX-Ekwo-Api-Keyis read by PostgREST rather than by a test that set a session variable. The step a machine key reaches the API over HTTP is the only one that can say the transport works, and its failure names the two statementsekwo doctorprints when a project would not let the migration write that setting.
npm run build npm run e2e:supabaseIt reads everything from the environment and writes no secret anywhere:
Variable What EKWO_DB_URLthe pooler connection string of the project SUPABASE_URLhttps://<ref>.supabase.coSUPABASE_ANON_KEYthe anon key — what a real client sends SUPABASE_SERVICE_ROLE_KEYused once, by ekwo init, to create the administratorEKWO_E2E_COUNTRYthe pack to install. No default: a default country is a chart of accounts nobody chose EKWO_E2E_CHART/EKWO_E2E_LANGUAGErequired whenever the pack carries more than one of either — ekwo initrefuses to pick for you when there is nobody to ask, which is the right answer and the first thing this script foundEKWO_E2E_FISCAL_YEAR_STARTrequired for a pack that names no month to open the year on (GB): the first day of the 2026 year EKWO_E2E_VAT_PERIODrequired for a pack whose periodic return is filed monthly or quarterly depending on the company (LU): monthorquarterEKWO_E2E_ADMIN_EMAIL/EKWO_E2E_ADMIN_PASSWORDthe administrator it creates and signs in as EKWO_E2E_PREVIOUSoptional: install that release first, so the run upgrades an installation instead of creating one. Left out, those steps are skipped rather than passed EKWO_E2E_LOAD_DOCUMENTSoptional: multiply the books to that many documents and time the six hot paths, over SQL and over PostgREST EKWO_E2E_SECOND_COUNTRY--multi-countryonly: the pack of the second company, another country thanEKWO_E2E_COUNTRY.EKWO_E2E_SECOND_CHART,EKWO_E2E_SECOND_LANGUAGEandEKWO_E2E_SECOND_FISCAL_YEAR_STARTas for the firstPoint
EKWO_E2E_PREVIOUSat the last tag. It is the only way the run exercises what a user will actually do. It takes a path to a built binary of an older checkout:git worktree add /tmp/prev v0.2.0 (cd /tmp/prev && npm ci && npm run build) EKWO_E2E_PREVIOUS=/tmp/prev/packages/cli/dist/bin.js npm run e2e:supabaseFrom
0.4.1on the CLI is on npm, andekwo-os@<x.y.z>works in the same variable — the script runs it withnpx --packagefrom an empty directory, because from inside this repository the workspace calledekwo-osanswers for the name andnpxfinds noekwoto run. A release older than that was never published under that name, so for those the path is the only form that works.--multi-countryruns the other installation:ekwo init --no-company, thenekwo company newin two countries, checked in the database and through PostgREST as the administrator. It books nothing, and needs its own empty project or--reset. First run on 22 September 2026, before any release carried it, on a throwaway project ineu-west-3, deleted afterwards: BE (defaultchart,nl) and FR (fr), 11 steps, all green, 62 s, the install alone 43 s. Played again the same day on a second throwaway project, once with the new keys (sb_secret_…for the install,sb_publishable_…for the client) and once with the legacy JWTs after--reset: all green both times, about 58 s each. Run it with both forms of key: a new project hands out the new ones first.The last run: 22 September 2026, for
0.7.0, on a throwaway project ineu-west-3(Postgres 17.6, session pooleraws-1), deleted afterwards. BE, EE, FR, GB (year opening 1 April), LU (quarterly) and US, each upgraded fromekwo-os@0.6.0on npm — 3 migrations pending — and--resetbetween runs: 21 steps each, all green, about 75 s a run. Then--multi-countrywith the newer keys,sb_publishable_…as the anon key andsb_secret_…forekwo init:init --no-companyinstalled 44 packs and no company, twice with nothing created the second time,ekwo company newmade an Estonian and a British company (year opening 1 April) on their own packs, and the administrator saw both through PostgREST — 12 steps, all green, 58 s. No load run.The run before, on 22 September 2026, for
0.6.0, on a throwaway project ineu-west-3(Postgres 17.6, session pooleraws-1), deleted afterwards. BE, EE, FR, GB (year opening 1 April), LU (quarterly) and US, each upgraded fromekwo-os@0.5.0on npm — 2 migrations pending — and--resetbetween runs: 21 steps each, all green, about 78 s a run, the install of0.5.0alone taking 40 s of it. Four packs new in this release installed fresh: AT (de), CH (fr, quarterly), PL and SA (quarterly), 18 steps each, all green. On the FR run,ekwo import fecof the brick's own sample over PostgREST as the administrator:--dry-run --open-years --save-mappingwrote nothing and proposed eight accountsexact; the import with--mappingopened 2025 and posted five entries, 7 852.00 each side, with onebook_importsrow; the same file again was refused asimport_already_done. No load run.The run before, on 22 September 2026, for
0.5.0, on a throwaway project ineu-west-3(Postgres 17.6, session pooleraws-1), deleted afterwards. BE, EE, FR, GB (year opening 1 April), LU (quarterly) and US, each upgraded fromekwo-os@0.4.1on npm — 24 migrations pending, applied byekwo migratein about 10.5 s — and--resetbetween runs: 21 steps each, all green, about 67 s a run. Three packs new in this release installed fresh, since0.4.1never carried them: IE, ES (EKWO_E2E_LANGUAGE=es) and SN (monthly), 18 steps each, all green. JP stops at the sale invoice: the script takes the first plain domestic sale tax without reading its dates, and the plain one of that pack is the 3 % of 1989 to 1997 — the standard rate splits into a national and a local share, which the script does not yet book. No load run this time.The run before, on 19 September 2026, for
0.4.1: the same six packs, 21 steps each. What it found and what was fixed:ekwo initleft the modules out, soekwo statuscalled a fresh installation sixteen migrations behind; the script posted a New York sales tax on a supply it placed nowhere, found no purchase tax in a pack whose purchase tax is not recoverable, and asked a quarterly filer for a yearly return;npx ekwo-os@…ran from the repository found no binary; the load steps timed the return of an empty month. At 10 000 documents (FR, 90 000 ledger lines) every path is inside its budget over SQL, the slowest beingsuggest_contactsat 1.1 s; over PostgRESTsuggest_contactsis OVER, at 15 s — one HTTP call per bank line, 167 of them, from a client in Belgium. The project had self sign-up turned off and PostgREST's row cap raised to 100 000 through the Management API.Read the times, not only the marks. Every step of the table carries how long it took and the run carries its total. What is worth noticing is which step holds the release: a migration set that doubled since the last tag, a first query waiting on a cold project, a close that got slower as the ledger grew. A number that moved between two releases is the question; the total on its own answers nothing.
The project has to be empty, and has to be one nobody minds losing. The script installs an instance, an administrator and a company, books into them and closes a financial year, so it refuses a database that already holds an
instancerow. It deletes nothing on its own: what is left behind is the evidence. It is not in the CI and never will be — it costs money, and a shared throwaway project would be a project two releases install into at once.--resetempties the project so a failed run can be replayed. It drops the module schemas,publicandsupabase_migrations, and recreates the schema with the default privileges a Supabase project has. Since20260914151207the migrations no longer need that — they grant their own rights, by name — and the reset restores the defaults anyway, on purpose: a real project has them, and a reset that left them out would be a reset that quietly stopped exercising what that migration does about them. What the run should then find is the revoke working, which is the step named "the anonymous role reaches no table". Before all this, a reset that forgot the defaults left an installationekwo doctorcalled healthy and PostgREST answeredpermission denied for table companieson.npm run e2e:supabase -- --resetIt is as destructive as it sounds and it is deliberately not an
ekwocommand: an installer that can empty a database is one somebody points at the wrong connection string. For a throwaway project and nothing else.
Cutting it
Open a pull request with all of the above. The CI job Migrations are additive compares the branch against
mainand, on a push, against the latest tag: a published migration that was modified or deleted fails the build whatever route it took.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>The tag is what the additive-migrations job compares against from then on, which is why it is never moved and never deleted.
Publish a GitHub Release on that tag, with the changelog section of the version as its body.
npm
The packages live on npm under the ekwo-ai organisation, and the command line
as ekwo-os. Publishing is part of the release and comes after the tag, so a
version on npm is always a version someone can read the source of:
npm run build
npm run release:publish # the plan, and a dry run of every pack
npm login --auth-type=web # a person, in a browser: no token lives here
npm run release:publish -- --for-real # on the tag
scripts/publish.mjs reads the list from the workspaces: whatever is a
workspace and is not private is published, after every package of this
repository it depends on. The list used to be written here by hand, and a brick
was once in the repository and not in it. For real, the script refuses a
working tree that is not clean and a HEAD that no tag names, and it skips a
version the registry already holds — so a run that stopped half way is simply
run again.
The order is the dependency order: a package is published after everything it
depends on. The root workspace is private and is never published.
The command line is ekwo-os on npm and ekwo once installed. The
registry refuses the unscoped name ekwo as too close to two existing
packages, which a scoped name is never judged for — so the fourteen libraries
went out as @ekwo-ai/* at 0.4.0 and the CLI followed at 0.4.1 under the
name of the repository. npx ekwo-os init runs it without installing anything;
npm install -g ekwo-os puts a binary called ekwo on the path, and every
example that starts with ekwo assumes that.
An account with two-factor authentication on writes is asked to approve each
npm publish in a browser, and the script needs a real terminal for that: npm
only waits for the approval when it has one, and ends on EOTP when it does
not. Count one approval per package. The approval page offers to stop asking
for five minutes; on the 0.4.1 run that did not carry from one package to the
next, and fifteen packages were fifteen approvals. An approval that is not
given in time ends the run on a 404 from the registry's done address — run
the script again, it skips what is already there.
The MCP registry
@ekwo-ai/mcp is described to the official registry
(registry.modelcontextprotocol.io) by packages/mcp/server.json, under the
name ai.ekwo/mcp. The registry holds metadata only; the package stays on npm,
and the registry proves the two belong together by reading mcpName out of the
published manifest — so this step comes after npm, never before, and a
version published without mcpName (0.4.1 and older) cannot be listed.
The ai.ekwo namespace is proved by a TXT record on the apex of ekwo.ai —
not on a sub-label, where the registry does not look — set once. It needs
OpenSSL 3: the openssl of macOS is LibreSSL and has no Ed25519, so there use
$(brew --prefix openssl@3)/bin/openssl.
openssl genpkey -algorithm Ed25519 -out ekwo-mcp-registry.pem # kept out of this repository
echo "ekwo.ai. IN TXT \"v=MCPv1; k=ed25519; p=$(openssl pkey -in ekwo-mcp-registry.pem -pubout -outform DER | tail -c 32 | base64)\""
Then, on every release, from packages/mcp:
brew install mcp-publisher # or the binary from the registry's GitHub releases
mcp-publisher validate
mcp-publisher login dns --domain ekwo.ai \
--private-key "$(openssl pkey -in ekwo-mcp-registry.pem -noout -text | grep -A3 'priv:' | tail -n +2 | tr -d ' :\n')"
mcp-publisher publish
The registry is in preview: its own documentation warns that data may be
reset before general availability. Check the listing after a publish with
curl 'https://registry.modelcontextprotocol.io/v0/servers?search=ai.ekwo'.
After a release
[Unreleased] is empty and the next change starts a new section under it. A
published migration is never edited from v0.2.0 on — not to fix a typo in a
comment, not to correct a value. It has run on databases nobody here controls,
and the correction is a new file.