Skip to content

Deployment

This document describes how @xmachines/xmachines-js packages are built, versioned, and published to npm. All releases are automated through semantic-release running inside a GitLab CI pipeline. No manual npm publish commands are required for normal releases.


Deployment Targets

All @xmachines/* packages are published to the public npm registry (registry.npmjs.org). Every package uses "publishConfig": { "access": "public" } so scoped packages are accessible without an npm org subscription. See the Published Packages table for the full list.

TargetConfig FilePurpose
npm registryscripts/publish-release.mjs, on the tag pipelinePublish all public packages
GitLab Releasesscripts/publish-release.mjs, on the tag pipelineAttach tarball artifacts to the GitLab release tag
GitLab CI.gitlab-ci.ymlTrigger builds, tests, and releases on push / MR / tag

The root package.json is marked "private": true and is never published to npm.


Published Packages

The following packages are published on every release (derived from the workspace scan in release.config.mjs — every non-private package that is not a demo):

PackageDirectory
@xmachines/sharedpackages/shared
@xmachines/playpackages/play
@xmachines/play-actorpackages/play-actor
@xmachines/play-atompackages/play-atom
@xmachines/play-routerpackages/play-router
@xmachines/play-dompackages/play-dom
@xmachines/play-dom-routerpackages/play-dom-router
@xmachines/play-reactpackages/play-react
@xmachines/play-react-routerpackages/play-react-router
@xmachines/play-solidpackages/play-solid
@xmachines/play-solid-routerpackages/play-solid-router
@xmachines/play-sveltepackages/play-svelte
@xmachines/play-sveltekit-routerpackages/play-sveltekit-router
@xmachines/play-svelte-spa-routerpackages/play-svelte-spa-router
@xmachines/play-tanstack-react-routerpackages/play-tanstack-react-router
@xmachines/play-tanstack-solid-routerpackages/play-tanstack-solid-router
@xmachines/play-vuepackages/play-vue
@xmachines/play-vue-routerpackages/play-vue-router
@xmachines/play-xstatepackages/play-xstate
@xmachines/docspackages/docs

Example demo packages (under packages/*/examples/demo) are not published to npm ("npmPublish": false) but are still packed into release tarballs and attached as GitLab release assets for testing.

Each published package includes only the directories listed in its files field: dist, README.md, and LICENSE (or equivalent for packages without a compilation step).


Build Pipeline

CI Pipeline Stages

Pipeline runs are triggered by .gitlab-ci.yml under the following conditions:

TriggerPipeline runs
Push to main / rc / beta / alpha / preFull pipeline; the release job waits to be started
Merge requestFull pipeline, no release job
Web / API / trigger / parent pipelineFull pipeline

No rule admits a tag pipeline, so pushing a tag runs nothing.

The pipeline includes two reusable CI components, both pinned in .gitlab-ci.yml — which is the version to trust, rather than a number repeated here:

  • to-be-continuous/node/gitlab-ci-node — handles install, lint, build, test, and audit
  • to-be-continuous/semantic-release/gitlab-ci-semrel — runs semantic-release on eligible branches

The node-build job runs one command:

Terminal window
pnpm run build # vite build (JavaScript) && tsc --build (declarations)

It runs no test. The test job owns the unit suite, so node-build skips its own with NODE_TEST_ARGS. It also builds no documentation: the docs job below does that.

Coverage comes from the test job, through the regex:

/All files[^|]*\|[^|]*\s+([\d\.]+)/

That job publishes the JUnit results as reports/test-results.xml and the Cobertura coverage as reports/cobertura-coverage.xml. vitest.config.ts names both paths, and COVERAGE_REPORTS_DIR points them at reports/.

Docs Job

The docs job runs pnpm run docs — the full build, then TypeDoc, then the formatter. It runs automatically on main and on the merge train, and it waits for a click on a merge request. It declares needs: [], because it compiles what it documents.

TypeDoc treats a warning as an error. This job is therefore the gate that reports a @see or a {@link} that names a symbol a rename removed. Before it existed, node-build regenerated the reference on every push, which put a second pass over every package on a branch that is pushed to many times.

The click blocks nothing on a merge request. The merge train starts the job by itself, so the train is the gate. The release-pack-smoke job below uses the same rules for the same reason.

The job declares stage: test. A manual job that blocks holds every later stage until it runs, so build would keep the unit suite, the browser suite and release-pack-smoke waiting for a click. Jobs in the same stage are not affected.

The job archives the two trees that TypeDoc writes as reports/docs-reference.tar.gz, and it keeps the archive as an artifact.

The test:docs job crawls the reference that the docs job generated. It declares needs: [docs], so it starts when docs passes. On a merge request it shows skipped until someone starts docs.

The crawl is packages/docs/test-generated/links.test.mjs. It follows every relative link of api/ and examples/@xmachines/, and it checks the file and the anchor of each link. TypeDoc cannot hold this rule: the routeOutput plugin moves a demo package to the examples tree after the link validator runs. The job publishes the result as a JUnit report.

The job deletes both trees before it unpacks the archive. GitLab unpacks an artifact over the checkout and deletes no file, and the checkout holds the reference of the last release. Without the delete, a page that TypeDoc no longer writes stays in the checkout, and the crawl reads its links.

The job has the rules of docs without the click. A job that needs a job which is absent from the pipeline fails the creation of the pipeline, so the two sets of rules must match. tests/docs-ci-jobs.test.ts holds the list of trees equal to the list that the release stages.

To run the same check on your machine, run pnpm run docs and then pnpm run test:docs.

Release Job Detail

The semantic-release CI job is configured with:

semantic-release:
id_tokens:
NPM_ID_TOKEN:
aud: "npm:registry.npmjs.org"
artifacts:
when: always
expire_in: 1 week
paths:
- dist/releases/*.tgz

The job uses a GitLab OIDC id token (NPM_ID_TOKEN) scoped to npm:registry.npmjs.org rather than a static NPM_TOKEN.

The semantic-release command is called with --no-ci to allow it to run inside CI without triggering its own CI-environment mode.

Release Smoke Test Job

The release-pack-smoke job is defined in .gitlab-ci.yml and runs on the same trigger rules as other CI jobs (automatically on main pushes and tags, manually on MRs):

Terminal window
pnpm run build
node scripts/release-pack-smoke.mjs

scripts/release-pack-smoke.mjs:

  1. Asks scripts/lib/release-packages.mjs which packages a release publishes. publish-release.mjs and release.config.mjs ask the same module, so the three cannot disagree.
  2. For each publishable package: runs npm pack --json in the package directory.
  3. Creates a temporary directory, runs npm init -y, and installs the local tarball with --ignore-scripts.
  4. Asserts the install succeeds — any missing files, broken exports, or pack-time errors surface here.
  5. Cleans up temp directories and tarballs after each check.

This smoke test runs before the actual publish so packaging problems are caught in CI rather than on npm.


Semantic Release Process

Branch Model

Releases are driven by commit history on the release branches:

BranchChannelTag formatBehaviour
mainstable (default)v1.2.3Full production release
rcrcv1.2.3-rc.NRelease candidate pre-release
betabetav1.2.3-beta.NBeta pre-release
alphaalphav1.2.3-alpha.NAlpha pre-release
preprev1.2.3-pre.NPre-release

The channel becomes the npm dist-tag verbatim, which is why none of them contains a slash: npm reads a slash in a version selector as a GitHub shorthand, so such a tag cannot be installed.

Tag format is v${version} (e.g., v1.0.0-beta.46).

Prepare Phase

Before any package is published, semantic-release runs a sequence of @semantic-release/exec prepare steps defined in release.config.mjs:

Terminal window
corepack enable
pnpm install --frozen-lockfile
node scripts/set-workspace-versions.mjs ${nextRelease.version}
pnpm run build
pnpm --filter @xmachines/docs run typedoc --gitRevision v${nextRelease.version}
pnpm --filter @xmachines/docs run format
git add -A packages/docs/api packages/docs/examples/@xmachines packages/docs/examples/README.md

Step by step:

  1. Enable corepack — activates the pinned pnpm version.
  2. Install — pnpm install --frozen-lockfile installs the exact dependency versions recorded in the lockfile.
  3. Sync versions — scripts/set-workspace-versions.mjs sets the same release version across every package.json in the workspace (root + all packages), including cross-package workspace dependency ranges.
  4. Build — runs vite build && tsc --build to produce compiled dist/ output — JavaScript then declarations — in all packages.
  5. Generate API docs — runs TypeDoc to regenerate packages/docs/api/ and packages/docs/examples/@xmachines/ at the release git revision. TypeDoc also rewrites the demo lists of packages/docs/examples/README.md.
  6. Format docs — runs oxfmt on the docs package to ensure consistent formatting.
  7. Stage the generated docs — git add -A packages/docs/api packages/docs/examples/@xmachines packages/docs/examples/README.md. The git plugin stages what git ls-files -m -o reports, and that never reports a DELETION. A page that TypeDoc removed, because an export was renamed or deleted, therefore stayed in git forever while the docs tarball packed from the working tree was correct. git add -A stages the removals, and the git plugin commits the index.

Publish Phase

semantic-release publishes nothing. It stops at the tag, and scripts/publish-release.mjs publishes from a pipeline that runs ON that tag:

Terminal window
node scripts/publish-release.mjs

The tag pipeline is what makes the provenance attestation meaningful. npm records the commit of the pipeline in the attestation. On a tag pipeline that commit IS the commit that the tag names, and it is the commit whose manifests declare this version. A publish from the branch pipeline attests the PARENT of the release commit instead: a revision that the tag does not name, and whose manifests still carry the previous version.

The script pins the internal dependencies, strips the source export condition, packs every workspace package into dist/releases/, publishes each publishable tarball with --provenance under the dist-tag of the version, and verifies that every one reached the registry. It then creates the GitLab release with the tarballs attached, and it restores the working tree.

The script is idempotent. It skips every package that the registry already serves at this version, so one retry of the job finishes a run that died partway — which is how the publish of 1.1.0 recovered from a credential that expired mid-run. Nothing to untag, nothing to force-push.

Post-Release Phase

This phase runs on the branch, BEFORE the tag pipeline publishes.

  1. @semantic-release/git commits updated files back to the repository:

    • CHANGELOG.md
    • package.json, pnpm-lock.yaml and pnpm-workspace.yaml (root)
    • The package.json of every scanned package, and the README.md of every one — the release stamps the version badge of a README at the same moment as the manifest
    • packages/docs/api/** (generated API documentation)
    • packages/docs/examples/@xmachines/** (generated demo documentation)
    • packages/docs/examples/README.md (TypeDoc rewrites its demo lists)

    The manifest list and the README list both come from the workspace scan, and not from a glob written by hand, so a package that a new pnpm-workspace pattern adds cannot be published while its manifest stays behind in git. A test asserts that every scanned package appears in both lists.

    Commit message format:

    chore(release): <version>

    The release notes are NOT interpolated into the message. @semantic-release/git runs git commit -m <message>, and the notes of the first stable release aggregate about 1600 commits — an -m value of several megabytes overflows the argument limit of the OS. The notes land in CHANGELOG.md, which the same commit carries.

    The message carries no [skip ci]. The release commit needs no pipeline of its own, but the TAG that points at it does: that pipeline is where the publish happens.

  2. The git plugin pushes the commit and the tag. semantic-release stops there. The GitLab release entry, with every dist/releases/*.tgz attached, is created later by scripts/publish-release.mjs on the tag pipeline — after the publish succeeded, so a release entry never advertises a tarball that npm refused.


Version Synchronisation

All packages in the monorepo share a single version at all times. This is enforced during release by scripts/set-workspace-versions.mjs, which:

  • Sets version in every workspace package.json (including the root) to the new release version.
  • Rewrites dependencies, devDependencies, peerDependencies, and optionalDependencies ranges for any @xmachines/* workspace package to the exact new version (no ^ or ~ prefix — a strict pinned version is written).

This means packages always depend on the exact same version of sibling packages that was released together.


Credentials and Protected Variables

VariableSourcePurpose
NPM_ID_TOKENGitLab CI OIDC (auto-generated per job)Authenticates semantic-release to npm registry via OIDC token exchange
GL_TOKEN / GITLAB_TOKENGitLab CI group variable (unprotected)Authenticates the git push back of semantic-release, and the GitLab release that publish-release.mjs creates
CI_JOB_TOKENGitLab CI built-inUsed by the to-be-continuous components for GitLab API calls

No static npm token is committed to the repository. The NPM_ID_TOKEN is a short-lived OIDC token generated for each CI job with audience npm:registry.npmjs.org.

To publish packages, a maintainer must:

  1. Have push access to a release-eligible branch (main, rc, beta, alpha, pre). Only main is protected; see the branch model above for what changes if GITLAB_TOKEN is ever made a protected variable.
  2. Ensure the GitLab project CI/CD variables include the required tokens.

Artifacts

Release tarballs are stored as GitLab CI artifacts for 1 week after each pipeline:

dist/releases/*.tgz

The same tarballs are attached to the GitLab Release entry as permanent downloadable assets. Tarballs are not committed to git (the dist/ directory is gitignored).

The post-release git commit commits the generated documentation (packages/docs/api/ and packages/docs/examples/@xmachines/) to git. The same commit holds the demo lists that TypeDoc writes into the hand-written packages/docs/examples/README.md. It also holds CHANGELOG.md, which @semantic-release/changelog writes.


Rollback Procedure

There is no automated rollback mechanism. To revert a release:

  1. npm deprecate (preferred) — deprecate the bad version on npm so it is hidden from installs:

    Terminal window
    npm deprecate @xmachines/play@<bad-version> "Deprecated: use <good-version>"

    Repeat for each affected package.

  2. npm unpublish — unpublish the bad version if it was published within 72 hours and no dependents exist:

    Terminal window
    npm unpublish @xmachines/play@<bad-version>
  3. Patch release — push a fix: commit to main (or the relevant branch) to trigger a patch version release via semantic-release. This is the preferred long-term approach for production issues.

  4. GitLab Release — delete or edit the GitLab Release entry via the GitLab UI or API to remove broken tarballs. This does not affect what is on npm.


Bootstrapping a New Package

A package that this workspace publishes for the FIRST time needs one manual publish before the release, and it is the one publish that a human runs.

The release authenticates to npm through OIDC and carries no static token. npm configures a trusted publisher on a package that EXISTS already, and a package exists after its first version. A name that npm has never seen therefore takes no publisher, and the release fails on that package alone: it publishes the rest, it creates the tag, and it leaves the new package missing — the state that Recovering a Failed Release describes.

Run this from the repository root, when the release is NEAR:

Terminal window
# Rehearse it first. It writes a tarball and publishes nothing.
node scripts/bootstrap-npm-package.mjs --dry-run play-pattern play-atom
node scripts/bootstrap-npm-package.mjs play-pattern play-atom

The script reads the real manifest of each package and publishes ONE version, 0.0.0-bootstrap.0, that holds the name, the metadata that npm shows, and a README that says why the version carries no code. The version carries a prerelease identifier, so a RANGE of a consumer selects it never. npm asks the maintainer for a one-time password, and the script gives npm the terminal to ask on. Pass a code without a terminal through the environment: npm_config_otp=<code> node scripts/bootstrap-npm-package.mjs <package>.

The bootstrap version takes the latest dist-tag. A package takes its latest from its FIRST version, whatever --tag says, because npm keeps no package without one. The script asks for the bootstrap tag and npm sets both, so npm install @xmachines/<name> gives the empty version until the real release replaces the tag. This is the second reason to bootstrap a name when the release is NEAR.

The registry also answers for the new package about three minutes after the publish, so a read that says “Not found” right after a publish that reported success is that delay.

Then, for EACH package:

  1. Open https://www.npmjs.com/package/<name>/access and add a trusted publisher:

    FieldValue
    Namespacexmachin-es
    Projectxmachines-js
    Pipeline file.gitlab-ci.yml
    Environmentnone

    The publish job declares no environment, so leaving that field set refuses the token.

  2. Cut the release, which publishes the real version through OIDC.

  3. Unpublish the bootstrap version:

    Terminal window
    npm unpublish @xmachines/<name>@0.0.0-bootstrap.0

npm accepts an unpublish for 72 HOURS after the publish. Bootstrap a name when the release is near, and not weeks before it, or the empty version stays on the registry for ever. @xmachines/play-url and @xmachines/play-view were bootstrapped this way for 4.0.0, and each one carries the release version alone today.


Recovering a Failed Release

If the job dies partway (e.g. one package’s trusted publisher isn’t set up), some packages are published, the git tag is created, and the rest are missing. To finish the release:

  1. Delete the release tag. semantic-release creates the tag before publishing, so a re-run while it exists is a no-op (semantic-release#3178):
    Terminal window
    glab api --method DELETE "projects/xmachin-es%2Fxmachines-js/repository/tags/vX.Y.Z"
  2. Re-run the semantic-release job.

The config detects a staged-but-untagged version (a recovery re-run) and automatically demotes any package already on npm at that version to pack-only — re-packing it for the release assets but not re-publishing it — so the re-run publishes only what’s missing, with no manual input. SEMREL_SKIP_STEPS (a regex matched against release step IDs) remains available as a manual override to force-skip specific steps.

First fix the underlying cause before re-running — e.g. configure the missing trusted publisher — otherwise the same packages fail again.


Local Release Dry-Run (Manual)

To preview what semantic-release would do without publishing:

Terminal window
# Install dependencies first
pnpm install --frozen-lockfile
# Run semantic-release in dry-run mode
pnpm exec semantic-release --dry-run --no-ci

To test package packaging locally (the same check run by release-pack-smoke in CI):

Terminal window
pnpm run build
node scripts/release-pack-smoke.mjs

This packs every publishable package, installs each tarball in a temporary project, and verifies the install succeeds.

To manually version and pack a single package for local inspection:

Terminal window
# From the repo root
pnpm --filter @xmachines/play run build
pnpm --filter @xmachines/play pack

Do not run npm publish manually. All publishing must go through semantic-release in CI to keep CHANGELOG.md, git tags, and npm versions in sync. Bootstrapping a New Package is the ONE exception, and it publishes a version that holds no code.


Monitoring

The project does not include application runtime monitoring (no Sentry, Datadog, or OpenTelemetry) — it is a library monorepo, not a deployed service.

Post-release health checks:

  • npm package availability — verify new versions appear on https://www.npmjs.com/package/@xmachines/<pkg> after the pipeline completes.
  • GitLab pipeline status — monitor the semantic-release job in the GitLab CI UI for the release commit.
  • GitLab Releases page — confirm the release tag and tarball assets appear at https://gitlab.com/xmachin-es/xmachines-js/-/releases.
  • packages/docs/api/ commit — confirm the post-release git commit updated API docs on the target branch.