Skip to content

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 ​

PackageWhy
@aletheia-dev/app-sdkWhat app authors import: the manifest, the handlers, the build and the testing helpers.
@aletheia-dev/app-hostThe host the SDK's testing helpers run a built module through; the runner uses it too.
@aletheia-dev/api-clientTyped client for the API, for your own back-office tools and integrations.
@aletheia-dev/collection-flow-uiReact inputs and step preview, for embedding collection flows in your own site.
@aletheia-dev/corePlatform types and zod schemas, published as a dependency of the packages above.
@aletheia-dev/collection-flowFlow 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 changeset

Pick 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/*.md files;
  • bumps version in each affected package.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-packages runs changeset version followed by pnpm 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-checks

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

  1. The @aletheia-dev scope on npm, owned by the project. Create the organisation at https://www.npmjs.com/org/create (packages use publishConfig.access: public, so a free organisation suffices). The plain aletheia organisation is taken by someone else, which is why the scope carries the -dev suffix.
  2. A way to publish on the machine that runs pnpm ship: NPM_TOKEN in its environment, a granular access token with read and write access to the @aletheia-dev packages 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 ship checks this before the merge, and the token never reaches a file: npm reads it from the environment.

Day to day ​

TaskCommand
Add a changeset to the current branchpnpm changeset
Add an empty changeset (no release needed)pnpm changeset --empty
See pending releasespnpm changeset status --verbose
Preview a tarballpnpm --filter <pkg> publish --dry-run --no-git-checks
Bump versions by hand (normally pnpm ship)pnpm version-packages
Publish what npm does not have yetbash 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.Z on main. Images and chart share that version: ghcr.io/akhiljames/aletheia-{api,worker,app-runner,migrate,web}:X.Y.Z and oci://ghcr.io/akhiljames/charts/aletheia version X.Y.Z, with the chart's appVersion set to the same value (image tags default to it). A -rc.1 suffix marks a prerelease. The release also writes the version into the root and apps/* package.json files (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:

  1. Make sure main is green and contains everything the release should.
  2. pnpm release X.Y.Z (0.2.0, 1.0.0-rc.1; no leading v). In a temporary worktree from origin/main it validates the version (^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$) and that the tag does not exist yet; sets version in the root and apps/* package.json files, in the OpenAPI document, and version and appVersion in deploy/helm/aletheia/Chart.yaml; lints the chart and regenerates the template snapshots (they embed the chart version); commits chore(release): vX.Y.Z on release/vX.Y.Z, runs the local CI on it, pushes it and opens its pull request.
  3. pnpm ship <that pull request>. After the merge, on the merged main, it tags vX.Y.Z; pushes the five images for linux/amd64 and linux/arm64 as sha-<short>, X.Y.Z and latest; packages the chart (helm package --version X.Y.Z --app-version X.Y.Z) and pushes it to oci://ghcr.io/akhiljames/charts; renders the notes with scripts/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 (--prerelease when 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 ​

TaskCommand
Lint the migrations your branch addspnpm migration:lint (--base <ref>, default origin/main)
Lint specific filespnpm migration:lint packages/db/drizzle/0014_x.sql
Check the classifier and the historical migrationsnode scripts/migration-lint.mjs --self-test
Preview the next release's notesnode 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 releasepnpm release 0.2.0, then pnpm ship <its pull request>

Released under the Apache-2.0 License.