Skip to content

Publishing a release

This procedure applies only to the public packages @mauriciodmo/framekit and @mauriciodmo/create-framekit. The root workspace, Studio, and the documentation site are private and are not publish targets. Keep the package versions, release-time npm dist-tag, exact registry specs, and final dist-tag as values supplied during the release. This page does not select any of them.

Authenticated release smokes must set FRAMEKIT_AUTH_ENABLED=true and provide a test bootstrap password explicitly. Open-mode checks must set FRAMEKIT_AUTH_ENABLED=false explicitly. Final npm publication and dist-tag promotion remain separate external release gates; validation commands never publish or promote packages.

Run release commands from the repository root with the Node.js and pnpm versions declared by the repository. Check the runtime contract first:

Terminal window
pnpm check:runtime

Build the public packages in order. Build FrameKit first so generated consumers and the creator workflow resolve its current built output:

Terminal window
pnpm --filter @mauriciodmo/framekit build
pnpm --filter @mauriciodmo/create-framekit build

Run the repository gates, then create both package archives for inspection:

Terminal window
pnpm lint
pnpm test
pnpm typecheck
pnpm build
pnpm --filter @mauriciodmo/framekit pack
pnpm --filter @mauriciodmo/create-framekit pack

The package manifests run a build during prepack, and each public package has lint, test, and type-check checks in prepublishOnly. The explicit build order above still matters when diagnosing stale output or inspecting the artifacts.

The version-independent pre-publication gate is:

Terminal window
pnpm smoke:tarballs

tooling/smoke-tarballs.mjs creates both tarballs in a temporary directory outside the checkout and checks their expected files, manifest targets, binary shebangs, and package boundaries. It rejects tests, secrets, browser binaries, workspace references, local links, local file: references, and checkout paths.

The same command runs two isolated consumer paths:

  • an independent consumer installed from the FrameKit tarball, with public export resolution and framekit generate, framekit check, and framekit build;
  • a consumer generated from the creator tarball, with a clean install, generated bindings, generate, check, production build, standalone start, HTTP readiness, authentication and route checks with FRAMEKIT_AUTH_ENABLED=true, and cleanup.

These are local artifact checks before publication. They do not prove that npm serves the package or that a Docker image can install it. The distribution guide and generated consumer guide describe the artifact and consumer boundaries in detail.

Review the current CI result for the change. The workflow runs the full Linux verification lane on Node.js 22.13.0 and 24, a focused Windows generated- consumer and packaging lane on Node.js 22.13.0, and a Chromium E2E lane on Linux. The Linux lane builds both public packages, runs lint, tests, type-checking, the workspace build, and package dry-run checks.

When the change affects browser or Studio behavior, run the current Chromium gate locally with the same browser installation used by CI:

Terminal window
pnpm install --frozen-lockfile
pnpm exec playwright install --with-deps chromium
pnpm test:e2e

The CI workflow does not run the tarball or Docker smoke scripts. Those remain separate release gates. See continuous integration and E2E and smoke tests for their scopes and limits.

Check the npm session and publish only the public package or packages included in this release. Use a release-time npm dist-tag that is not the final promotion tag. If both packages are being released, publish FrameKit first:

Terminal window
npm whoami
: "${PUBLISH_TAG:?Set the release-time npm dist-tag}"
# Run only the commands for packages included in this release.
pnpm --filter @mauriciodmo/framekit publish --access public --tag "$PUBLISH_TAG"
pnpm --filter @mauriciodmo/create-framekit publish --access public --tag "$PUBLISH_TAG"

Do not use npm publish --workspace or npm publish --prefix; this workspace defines package ownership through pnpm. Do not add --otp to the publish command. If npm requests an OTP, enter it in the interactive terminal.

After the exact FrameKit package is available from npm, run the registry-backed Docker check when the release includes the generated Docker consumer path:

Terminal window
pnpm smoke:docker -- <exact-published-framekit-version>

The script requires one exact published semver. It verifies the package through npm, builds and runs the generated image, checks its non-root node user and tini entrypoint, covers explicit open and authenticated modes, exercises authentication and PNG rendering in the authenticated mode, and checks persistence across container replacement. It is not a substitute for the local tarball smoke, unit tests, type-checking, or Chromium E2E.

Run the post-publication npm smoke only after the selected packages are available in the registry. The smoke checks both public package contracts, but the two packages do not have to be part of the same release. Supply exact, release-time values for CORE_SPEC and CREATOR_SPEC, plus one expected tag for each package:

  • For a package included in this handoff, use its exact newly published spec and the release-time tag used for that package’s publication.
  • For an unchanged package, use its existing exact published spec and its existing intended tag. Do not require the unchanged package to receive the new publication tag.

Use EXPECTED_FRAMEKIT_DIST_TAG for @mauriciodmo/framekit and EXPECTED_CREATE_FRAMEKIT_DIST_TAG for @mauriciodmo/create-framekit. If both packages are released together, both values may be the same release-time tag. If only one package is released, only that package uses the release-time tag; the other value remains the unchanged package’s intended tag.

The complete current registry smoke is reproducible from a Bash shell outside the checkout context used by the temporary consumer:

Terminal window
set -eu
: "${CORE_SPEC:?Set the exact @mauriciodmo/framekit npm spec}"
: "${CREATOR_SPEC:?Set the exact @mauriciodmo/create-framekit npm spec}"
: "${EXPECTED_FRAMEKIT_DIST_TAG:?Set FrameKit's expected npm dist-tag}"
: "${EXPECTED_CREATE_FRAMEKIT_DIST_TAG:?Set create-framekit's expected npm dist-tag}"
CORE_VERSION="$(npm view "$CORE_SPEC" version)"
CREATOR_VERSION="$(npm view "$CREATOR_SPEC" version)"
REGISTRY="$(npm config get registry)"
NPM_VERSION="$(npm --version)"
export CORE_VERSION CREATOR_VERSION REGISTRY NPM_VERSION
test "$CORE_SPEC" = "@mauriciodmo/framekit@$CORE_VERSION"
test "$CREATOR_SPEC" = "@mauriciodmo/create-framekit@$CREATOR_VERSION"
# Check each package's expected tag independently.
test "$(npm view @mauriciodmo/framekit "dist-tags.$EXPECTED_FRAMEKIT_DIST_TAG")" = "$CORE_VERSION"
test "$(npm view @mauriciodmo/create-framekit "dist-tags.$EXPECTED_CREATE_FRAMEKIT_DIST_TAG")" = "$CREATOR_VERSION"
SMOKE_DIR="$(mktemp -d)"
SERVER_PID=""
cleanup() {
status=$?
if test -n "$SERVER_PID"; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
if test -f "$SMOKE_DIR/start.log"; then
node --input-type=module - "$SMOKE_DIR/start.log" <<'NODE'
import { readFile } from 'node:fs/promises'
process.stdout.write(`--- standalone start.log ---\n${await readFile(process.argv[2], 'utf8')}\n`)
NODE
fi
rm -rf "$SMOKE_DIR"
exit "$status"
}
trap cleanup EXIT
# The runner and consumer are outside the FrameKit checkout.
mkdir "$SMOKE_DIR/runner"
cd "$SMOKE_DIR/runner"
npm init -y >/dev/null
npm install "$CREATOR_SPEC" "$CORE_SPEC"
test -x node_modules/.bin/create-framekit
test -x node_modules/.bin/framekit
test -f node_modules/@mauriciodmo/create-framekit/dist/cli.js
test -f node_modules/@mauriciodmo/framekit/bin/framekit.js
node --input-type=module <<'NODE'
for (const specifier of [
'@mauriciodmo/framekit',
'@mauriciodmo/framekit/client',
'@mauriciodmo/framekit/dev',
'@mauriciodmo/framekit/editor',
'@mauriciodmo/framekit/next',
'@mauriciodmo/framekit/server',
'@mauriciodmo/framekit/studio',
'@mauriciodmo/framekit/studio/root',
'@mauriciodmo/framekit/styles.css',
]) console.log(specifier, import.meta.resolve(specifier))
NODE
npx --no-install create-framekit "$SMOKE_DIR/consumer" -n
cd "$SMOKE_DIR/consumer"
CORE_VERSION="$CORE_VERSION" node --input-type=module <<'NODE'
import { readFile, writeFile } from 'node:fs/promises'
const packagePath = 'package.json'
const packageJson = JSON.parse(await readFile(packagePath, 'utf8'))
const declared = packageJson.dependencies?.['@mauriciodmo/framekit']
if (typeof declared !== 'string' || declared.length === 0) throw new Error('Creator template has no FrameKit dependency')
console.log(`Creator template FrameKit dependency: ${declared}`)
packageJson.dependencies['@mauriciodmo/framekit'] = process.env.CORE_VERSION
await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`)
NODE
npm install
CORE_VERSION="$CORE_VERSION" node --input-type=module <<'NODE'
import { readFile } from 'node:fs/promises'
const installed = JSON.parse(await readFile('node_modules/@mauriciodmo/framekit/package.json', 'utf8'))
if (installed.version !== process.env.CORE_VERSION) throw new Error(`Unexpected FrameKit version: ${installed.version}`)
NODE
npx --no-install framekit generate
npx --no-install framekit check
npx --no-install framekit build
npx --no-install framekit browser install --with-deps
test -f src/generated/framekit/templates.ts
PORT="$(node --input-type=module <<'NODE'
import { createServer } from 'node:net'
const server = createServer()
server.listen(0, 'localhost', () => {
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Could not reserve a local port')
console.log(address.port)
server.close()
})
NODE
)"
FRAMEKIT_AUTH_ENABLED=true FRAMEKIT_ADMIN_PASSWORD=framekit-registry-smoke-password FRAMEKIT_DATABASE_PATH=:memory: HOSTNAME=localhost PORT="$PORT" node node_modules/@mauriciodmo/framekit/bin/framekit.js start > "$SMOKE_DIR/start.log" 2>&1 &
SERVER_PID=$!
node --input-type=module - "$PORT" <<'NODE'
const port = process.argv[2]
const deadline = Date.now() + 30_000
while (Date.now() < deadline) {
try {
const response = await fetch(`http://localhost:${port}/login`)
if (response.ok) process.exit(0)
} catch {
// The standalone server may still be starting.
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
console.error(`Studio was not ready on port ${port}`)
process.exit(1)
NODE
node --input-type=module - "$PORT" <<'NODE'
const port = process.argv[2]
const origin = `http://localhost:${port}`
const request = { template: 'example' }
const missingAuth = await fetch(`${origin}/api/framekit/images/render`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(request)
})
if (missingAuth.status !== 401) throw new Error(`Expected 401 before login, got ${missingAuth.status}`)
const login = await fetch(`${origin}/api/framekit/login`, {
method: 'POST',
headers: { origin, 'content-type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: 'framekit-registry-smoke-password' })
})
const loginBody = await login.text()
if (login.status !== 200) throw new Error(`Login failed: ${login.status} ${loginBody}`)
const sessionCookie = login.headers.get('set-cookie')?.split(';', 1)[0]
if (!sessionCookie) throw new Error('Login did not return a session cookie')
const tokenResponse = await fetch(`${origin}/api/framekit/tokens`, {
method: 'POST',
headers: { cookie: sessionCookie, origin, 'content-type': 'application/json' },
body: JSON.stringify({ name: 'Registry smoke token' })
})
const tokenBody = await tokenResponse.json()
if (tokenResponse.status !== 201 || typeof tokenBody.token !== 'string') {
throw new Error(`Token creation failed: ${tokenResponse.status} ${JSON.stringify(tokenBody)}`)
}
const image = await fetch(`${origin}/api/framekit/images/render`, {
method: 'POST',
headers: { authorization: `Bearer ${tokenBody.token}`, 'content-type': 'application/json' },
body: JSON.stringify(request)
})
const imageBytes = new Uint8Array(await image.arrayBuffer())
const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10]
if (image.status !== 200 || image.headers.get('content-type') !== 'image/png' || !pngSignature.every((byte, index) => imageBytes[index] === byte)) {
throw new Error(`PNG render failed: ${image.status} ${image.headers.get('content-type')}`)
}
console.log('Authenticated registry smoke passed: login, token, and PNG render')
console.log(JSON.stringify({
registry: process.env.REGISTRY,
node: process.version,
npm: process.env.NPM_VERSION,
coreSpec: process.env.CORE_SPEC,
coreVersion: process.env.CORE_VERSION,
coreTag: process.env.EXPECTED_FRAMEKIT_DIST_TAG,
creatorSpec: process.env.CREATOR_SPEC,
creatorVersion: process.env.CREATOR_VERSION,
creatorTag: process.env.EXPECTED_CREATE_FRAMEKIT_DIST_TAG,
result: 'PASS'
}, null, 2))
NODE

Before cleanup runs, record CORE_SPEC, CREATOR_SPEC, both expected package tags, the resolved versions, registry, Node.js and npm versions, timestamp, PASS or FAIL, relevant command output, and $SMOKE_DIR/start.log. The creator’s declared FrameKit version or range and the installed exact core version belong in that record. The cleanup trap stops the standalone server and removes the temporary runner and consumer. Never publish or change a dist-tag as part of this check. A successful npm upload is not a successful registry gate; a registry-smoke failure blocks final promotion, not the initial upload.

Do not promote a package to its final dist-tag until the local publication gates, applicable CI and browser checks, tarball smoke, and post-publication registry checks have passed. After the Docker check when applicable, promote only the packages released in this handoff, using the exact versions returned by the registry smoke:

Terminal window
npm dist-tag add <package>@<resolved-version> <final-dist-tag>

The command above is a release-time handoff. This page intentionally does not choose a version or a final dist-tag. The final npm publication and promotion are separate external gates performed by the release owner.