Table of contents Show Hide
npm packages and desktop installers share some source but have different version entry points and artifacts. One successful script cannot replace final acceptance in the registry and installer. This article reads ChatCrystal’s pinned release helper and workflow, then checks tag routing offline. It does not publish, push tags, or package the application.
Two version entry points and three tag scopes
The root package.json manages the desktop version. server/package.json manages npm package chatcrystal, with bin mapping crystal to dist/server/src/cli/index.js. Both are 0.6.1 in this commit but can evolve independently.
| Tag | Path selected by release.yml | Version origin |
|---|---|---|
| npm-vVERSION | npm publishing | Remove npm-v from the tag |
| electron-vVERSION | Electron publishing | Remove electron-v from the tag |
| vVERSION | Both paths | Electron takes the tag; npm reads server/package.json |
The last row matters: encountering v0.6.2 does not force both versions to 0.6.2. The normal release helper synchronizes appropriate manifests first. A manually constructed tag without a server version update can still make the npm job attempt an already-published version.
SemVer expresses a compatibility contract; incremented numbers do not establish compatibility. Database changes also depend on migration and rollback. The helper’s explicit-version recognition is not complete SemVer validation, so inspect later checks and the actual change scope.
The version helper performs real external actions
release.mjs selects all, npm, or electron through RELEASE_TARGET and changes appropriate package.json files. The npm path also updates server.json and its package version. It then updates the lockfile using pnpm install –lockfile-only, commits selected files, creates an annotated tag, and pushes to origin/main.
The helper checks git diff and staged changes, without checking untracked files. It does not run lint or tests first. A “clean-tree check” cannot be described as covering every file or every release gate. pnpm release, release:npm, and release:electron are real release entry points, unsuitable as side-effect-free reading exercises.
The script also does not establish automatic reversal of earlier manifest changes, commits, or tags after failure. Recovery should begin by checking remote and publication states, rather than interpreting a local exit code as the state of the whole release.
The npm job checks more than compilation
The npm job in release.yml uses Node 24.20.0 and locked pnpm installation. It runs lint, tests, dependency audit and signature checks, server build, version synchronization, MCP smoke, and package-content validation before npm publish –provenance –access public.
The files manifest includes dist/server, dist/shared, README.md, and server.json. npm can automatically include other files, so files alone cannot establish exclusion of all tests and sources. check-npm-package.mjs reads npm pack –dry-run –json, checks required entries, types, and seed data, rejects compiled tests, maps, and non-declaration TypeScript, and validates server.json identity, versions, stdio transport, and the file-count limit.
The workflow declares an npm-publish environment, id-token:write, and provenance. Trusted publishing still requires registry configuration and supported conditions; see npm’s official documentation. The current file lacks the NODE_AUTH_TOKEN fallback described in the original draft. An environment name does not establish that manual approval is configured either.
Electron has its own resources and acceptance checks
The Windows job builds server/client, compiles the main process, synchronizes the desktop version, and runs MCP smoke. electron-builder.yml configures x64 NSIS, installation-directory selection, current-user installation, and shortcuts. sql-wasm.wasm is an extra resource, while sql.js and .node files have asarUnpack rules.
The builder packages with –publish never. The workflow then extracts CHANGELOG content, creates a GitHub Release, and uploads exe and update manifests. Cache keys separately follow Electron version and builder configuration. This article measures no hit rates, package savings, or release durations.
The pinned packaging retry block also has a failure-exit problem: it captures $? after the if statement’s fi. With a failed condition and no else branch, that if statement itself returns 0, potentially letting the last failed attempt exit successfully. An offline substitution of false for the builder and a no-op for waiting reproduces exit 0. Capturing the failure code inside else instead produces exit 1. This is a verified boundary of the pinned implementation; this article changes no workflow and runs no real builder.
Retries therefore do not establish that exhausted failures block progress. Check immediate success, failure followed by success, and total failure, then verify installer execution and resources such as WASM. Uploading files alone does not establish a usable desktop application.
Check routing and package fixtures offline
Run the following lesson.mjs with Node 24+ without dependencies. It handles only in-memory strings and arrays. This simplified teaching plan accepts stable three-part numeric versions only. It is not the complete helper, a SemVer parser, or the actual package checker. Version 0.6.2 is a constructed example, rather than a publication claim.
import assert from 'node:assert/strict';
function plan(tag, npmManifestVersion) {
if (/^npm-v\d+\.\d+\.\d+$/.test(tag)) {
return { npm: tag.slice(5), electron: null };
}
if (/^electron-v\d+\.\d+\.\d+$/.test(tag)) {
return { npm: null, electron: tag.slice(10) };
}
if (/^v\d+\.\d+\.\d+$/.test(tag)) {
return { npm: npmManifestVersion, electron: tag.slice(1) };
}
throw new Error('unsupported teaching tag');
}
assert.deepEqual(plan('npm-v0.6.2', '0.6.1'), { npm: '0.6.2', electron: null });
assert.deepEqual(plan('electron-v0.6.2', '0.6.1'), { npm: null, electron: '0.6.2' });
assert.deepEqual(plan('v0.6.2', '0.6.1'), { npm: '0.6.1', electron: '0.6.2' });
assert.throws(() => plan('release-latest', '0.6.1'), /unsupported/);
function checkFiles(files) {
assert.ok(files.includes('dist/server/src/cli/index.js'));
assert.ok(files.includes('server.json'));
assert.ok(!files.some((file) => /\.map$|\.test\.js$/.test(file)));
}
const files = ['dist/server/src/cli/index.js', 'server.json'];
checkFiles(files);
assert.throws(() => checkFiles(['server.json']));
assert.throws(() => checkFiles([...files, 'dist/leaked.test.js']));
console.log('release-plan and package-fixture guards pass');
Change the full-release branch from npmManifestVersion to tag.slice(1) and its version-origin assertion should fail; restore it and the program passes. Removing the forbidden-test-file assertion also makes the final assert.throws fail. These controls verify the teaching guards, without establishing successful registry, OIDC, workflow-permission, or installer behavior.
Complete acceptance by checking platform state
Tags or manual dispatch select scope. npm and Electron jobs both depend on prepare but can succeed or fail independently. One failure does not automatically undo publication in the other path. Pushing a tag is not a publication transaction: record job conclusions, registry versions, and Release assets, then choose recovery or a later version from actual state.
Automation still needs a concrete acceptance scope. Package entries, resources, protocol smoke, installation and startup, and failure exits each need evidence. A message that the script pushed its tag cannot establish complete publication success.