Appearance
Releasing
Two things are released from this repository on independent cadences: the npm packages (Changesets, automated from main) and the platform (images and Helm chart, tagged manually).
Packages
What is published
| Package | Why |
|---|---|
@aletheia-dev/app-sdk | What app authors import: the manifest, the handlers, the build and the testing helpers. |
@aletheia-dev/app-host | The host the SDK's testing helpers run a built module through; the runner uses it too. |
@aletheia-dev/api-client | Typed client for the API, for your own back-office tools and integrations. |
@aletheia-dev/collection-flow-ui | React inputs and step preview, for embedding collection flows in your own site. |
@aletheia-dev/core | Platform types and zod schemas, published as a dependency of the packages above. |
@aletheia-dev/collection-flow | Flow logic (visibility, navigation, validation), a dependency of collection-flow-ui. |
@aletheia-dev/core and @aletheia-dev/collection-flow carry no stability promise before 1.0: they are the platform's internal types and change with it. The app SDK follows semver on its own cadence (see its README). Everything else (the services under apps/, the catalogue's apps under catalog/, db, auth, the engines, storage, documents, telemetry and so on) is "private": true and never reaches npm.
@aletheia-dev/app-sdk depends on @aletheia-dev/app-host, which runs a built module in the author's tests exactly as the platform's runner does, and on @aletheia-dev/core, the contract both sides validate against; app-host's only workspace dependency is core. zod is a peer range of the SDK (^4.0.0), so the schemas in an app's manifest are the app's own zod instance's. The code that runs inside a module imports neither core nor anything from Node: the SDK's runtime declares the shapes it shares with core itself (src/types.ts).
Changesets for contributors
Versioning is driven by Changesets. A PR that changes one of the published packages adds a changeset describing the change for that package's changelog:
sh
pnpm changesetPick the packages and the bump (patch, minor, major; while a package is 0.x, minor is the bump for anything that changes the contract) and write the summary for its users, not for the reviewer. The command writes .changeset/<name>.md; commit it with the change. One changeset per PR is the norm; several are fine when different packages need different summaries.
CI's verify job runs pnpm changeset status --since=origin/main on every pull request and fails when a published package changed without a changeset. Private packages are invisible to Changesets (privatePackages: { version: false, tag: false } in .changeset/config.json), so PRs that touch only the services, the catalogue or private packages need no changeset, and a release of @aletheia-dev/core does not patch-bump the private packages that depend on it. The version fields of the services under apps/ belong to the platform release (below), not to Changesets. A change to a published package that does not warrant a release (a test, a comment) satisfies the check with pnpm changeset --empty.
The version PR
pnpm ship (scripts/ci-local/main/packages.sh) runs after every merge into main. While changesets are pending it opens (or refreshes) a pull request titled chore: version packages from the branch changeset-release/main that:
- deletes the consumed
.changeset/*.mdfiles; - bumps
versionin each affectedpackage.json, including dependents inside the workspace (updateInternalDependencies: patch); - prepends the summaries to each package's
CHANGELOG.md, each under its commit's short hash (Changesets' default changelog, which links nowhere: the repository is private); - refreshes
pnpm-lock.yaml(pnpm version-packagesrunschangeset versionfollowed bypnpm install --lockfile-only).
Review that PR like any other: the changelog text is what users read. Merging it with pnpm ship is the release. Like any pull request it is checked when it is shipped: pnpm ship runs every job on it before the merge, and checks the npm login and that each package packs.
Publishing
When main has no pending changesets (the version PR was just merged), the same step publishes every public package whose version is not yet on the registry: pnpm release-packages runs pnpm build for the whole workspace, then changeset publish, which runs pnpm publish --access public and tags the commit (@aletheia-dev/app-sdk@0.1.0); the step then pushes those tags and creates one GitHub release per package with its changelog entry. workspace:* ranges are rewritten to the published versions by pnpm at pack time.
Packages published from a workstation carry no provenance attestation: npm issues those only to builds on a CI provider with an identity token.
To check what a release will contain without publishing, pack from a clean build:
sh
pnpm build
pnpm --filter @aletheia-dev/app-sdk publish --dry-run --no-git-checksThe tarball holds dist/, package.json, the root LICENSE (pnpm copies it in) and the package's README.md and CHANGELOG.md where they exist; sources and tests stay out (files in each package.json).
Prerequisites
Publishing needs two things; until they exist the publish step stops with a message:
- The
@aletheia-devscope on npm, owned by the project. Create the organisation at https://www.npmjs.com/org/create (packages usepublishConfig.access: public, so a free organisation suffices). The plainaletheiaorganisation is taken by someone else, which is why the scope carries the-devsuffix. - A way to publish on the machine that runs
pnpm ship:NPM_TOKENin its environment, a granular access token with read and write access to the@aletheia-devpackages that bypasses two-factor authentication; or an npm login on an account with two-factor authentication on, where each publish asks for a one-time password. npm refuses a login without two-factor authentication.pnpm shipchecks this before the merge, and the token never reaches a file: npm reads it from the environment.
Day to day
| Task | Command |
|---|---|
| Add a changeset to the current branch | pnpm changeset |
| Add an empty changeset (no release needed) | pnpm changeset --empty |
| See pending releases | pnpm changeset status --verbose |
| Preview a tarball | pnpm --filter <pkg> publish --dry-run --no-git-checks |
Bump versions by hand (normally pnpm ship) | pnpm version-packages |
| Publish what npm does not have yet | bash scripts/ci-local/main/packages.sh on main |
The version PR skips the changeset status check (it is the PR that consumes the changesets); the rest of the local CI runs on it like on any pull request.
Platform releases
Versioning
Two version lines, deliberately decoupled:
- npm packages follow semver each on their own cadence through Changesets (above). An SDK release does not imply a platform release and vice versa.
- The platform (the five images, the catalogue's apps the API image carries, and the Helm chart) follows semver as a git tag
vX.Y.Zonmain. Images and chart share that version:ghcr.io/akhiljames/aletheia-{api,worker,app-runner,migrate,web}:X.Y.Zandoci://ghcr.io/akhiljames/charts/aletheiaversionX.Y.Z, with the chart'sappVersionset to the same value (image tags default to it). A-rc.1suffix marks a prerelease. The release also writes the version into the root andapps/*package.jsonfiles (Changesets leaves private packages alone,privatePackages.version: false); the API and worker report it at runtime.
Patch for fixes, minor for features and additive migrations, major for anything an operator has to act on before upgrading: a destructive migration (migration:destructive) or a workflow incompatibility (workflow:incompatible). Both are called out in the release notes.
The catalogue
The platform apps under catalog/ are released with the platform, not on npm: the image build compiles them with the pinned tools and assembles catalog/dist (pnpm catalog:index), and the API image carries it (App catalogue). Each app keeps its own version, the version of its manifest, which a change to the app must bump: a version is one immutable module. A rebuilt image's module of an unchanged app hashes differently (builds are not byte-reproducible) and is ignored in favour of the published one, while an API whose catalogue names a published version with another manifest refuses to start. A change to an app's code that leaves its manifest as it was is only picked up under a new version. When a release's API starts, it publishes the app versions it does not have yet and withdraws the platform versions the release no longer carries; tenants' installs stay on the versions they run until an administrator upgrades them.
Cutting a release
Main only takes pull requests, so a release is two commands:
- Make sure
mainis green and contains everything the release should. pnpm release X.Y.Z(0.2.0,1.0.0-rc.1; no leadingv). In a temporary worktree fromorigin/mainit validates the version (^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$) and that the tag does not exist yet; setsversionin the root andapps/*package.jsonfiles, in the OpenAPI document, andversionandappVersionindeploy/helm/aletheia/Chart.yaml; lints the chart and regenerates the template snapshots (they embed the chart version); commitschore(release): vX.Y.Zonrelease/vX.Y.Z, runs the local CI on it, pushes it and opens its pull request.pnpm ship <that pull request>. After the merge, on the mergedmain, it tagsvX.Y.Z; pushes the five images forlinux/amd64andlinux/arm64assha-<short>,X.Y.Zandlatest; packages the chart (helm package --version X.Y.Z --app-version X.Y.Z) and pushes it tooci://ghcr.io/akhiljames/charts; renders the notes withscripts/release-notes.mjs <previous tag> vX.Y.Z(the root commit stands in for the previous tag on the first release); and creates the GitHub release with the chart tarball attached (--prereleasewhen the version has a suffix).
The image and chart pushes need docker login ghcr.io and helm registry login ghcr.io on that machine. If the chart push or the GitHub release fails after the tag was pushed, do not run the release again with the same version (the tag check refuses): finish by hand with helm package/helm push and gh release create vX.Y.Z --notes-file <(node scripts/release-notes.mjs <prev> vX.Y.Z).
Migration gates
Migrations are expand/contract: a release's migrations must leave the previous release running against the migrated schema, because the migrate job runs before the new API and worker roll out and a rollback must not need a down-migration. Two checks in the migrations job of the local CI enforce this (scripts/ci-local/migrations.sh):
Additive lint. On every pull request, pnpm migration:lint --base origin/<base> runs scripts/migration-lint.mjs over the migration files the PR adds (never over history). Flagged: DROP TABLE/SCHEMA/TYPE/VIEW/SEQUENCE/DOMAIN, ALTER TABLE … DROP COLUMN|CONSTRAINT, ALTER COLUMN … TYPE, ALTER COLUMN … SET NOT NULL, ADD COLUMN … NOT NULL without a DEFAULT (or GENERATED), TRUNCATE, DELETE FROM and any RENAME. Allowed: DROP INDEX (with or without IF EXISTS), DROP POLICY, ALTER TYPE … ADD VALUE, DROP NOT NULL, DROP DEFAULT, CREATE TABLE with NOT NULL columns, grants, UPDATE/INSERT. Comments, string literals and DO $$ … $$ bodies are ignored. Each finding prints as file:line: reason and fails the job.
When the contract step is intended, label the PR migration:destructive and write the expand/contract note in its description: which release introduced the replacement (expand), why nothing running still reads the dropped object, and what an operator must do before upgrading. With the label the lint lists its findings as a notice instead of failing, and the release notes mark the migration as destructive and quote the warning. The usual shape is two releases: N adds the new column/table and backfills (additive), N+1 drops the old one (destructive, labelled) once no supported release reads it.
Upgrade from the previous release. The same job creates a second database, applies the migrations as they were at the latest v* tag (git archive <tag> packages/db/drizzle, run with MIGRATIONS_FOLDER=<that folder>; --folder <dir> on the migrate CLI is the equivalent), then the current migrations on top, then the current seed. This proves the journal continues from the released one and that new migrations apply to a schema that already holds the released tables. It is skipped with a notice until the first tag exists. The fresh migrate-and-seed path still runs first.
Workflow compatibility
A change to the workflow interpreter that breaks determinism for in-flight runs (a new step kind that changes command order, a reordered activity) must carry the label workflow:incompatible or that string in the PR description or commit message. The release notes then open the "Workflow compatibility" section with the instruction to drain running workflows before upgrading the worker; otherwise they state that in-flight runs replay on the new worker. The replay test over committed histories (Performance) enforces the label: a change that fails replay without the label does not merge.
Rolling back
Images and chart are immutable per version, so a rollback is the previous coordinates:
sh
helm upgrade --install aletheia oci://ghcr.io/akhiljames/charts/aletheia --version <previous> \
--namespace aletheia --reuse-values
# or, with the same chart, only the images:
helm upgrade aletheia ... --set image.tag=<previous>Migrations are additive (the gates above), so the previous release runs against the migrated schema and there are no down-migrations; the migrate hook is idempotent and finds nothing to do. The one exception is a release whose notes carry a destructive migration: it cannot be rolled back past the contract step, which is why such releases are major and the note on the PR says what to do.
Local commands
| Task | Command |
|---|---|
| Lint the migrations your branch adds | pnpm migration:lint (--base <ref>, default origin/main) |
| Lint specific files | pnpm migration:lint packages/db/drizzle/0014_x.sql |
| Check the classifier and the historical migrations | node scripts/migration-lint.mjs --self-test |
| Preview the next release's notes | node scripts/release-notes.mjs v0.1.0 HEAD |
| Apply another folder's migrations (upgrade check) | pnpm --filter @aletheia-dev/db migrate --folder /tmp/prev/packages/db/drizzle |
| Cut a release | pnpm release 0.2.0, then pnpm ship <its pull request> |