@smoothbricks/cli
v0.11.17
Published
SmoothBricks monorepo automation CLI
Readme
@smoothbricks/cli
@smoothbricks/cli provides smoo, the SmoothBricks monorepo automation CLI. It is the control plane for shared CI,
release, Git hook, package metadata, and publish validation conventions across SmoothBricks-style repositories.
The tool is intentionally convention-over-configuration. SmoothBricks repos use Nx, Bun, Nix, and devenv
(activated natively, or through direnv in repos that have not migrated), so smoo assumes those pieces exist instead
of adding another local config file. Repos should be made correct by running the mutating initialization path, then kept
correct by the read-only validation path.
Install
Add the CLI to the root workspace:
bun add -d @smoothbricks/cliThe package exposes a Bun-native executable:
smoo --helpSmoothBricks itself self-hosts before dist exists by using tooling/smoo, which imports packages/cli/src/cli.ts
directly. Published installs use the package binary in bin/smoo, which imports built JavaScript from dist.
Core Commands
smoo monorepo init [--runtime-only] [--sync-runtime]
smoo monorepo validate [--fail-fast] [--only-if-new-workspace-package]
smoo monorepo update
smoo monorepo check
smoo monorepo diff
smoo monorepo validate-commit-msg <commit-msg-file>
smoo monorepo sync-bun-lockfile-versions
smoo monorepo list-release-packages [--fail-empty] [--github-output <path>]
smoo monorepo validate-public-tags
smoo monorepo setup-test-tracing (--all | --projects <projects>) [--dry-run]
smoo release npm-status
smoo release repair-pending [--dry-run]
smoo release version --bump <auto|patch|minor|major|prerelease> [--projects <projects|all>] [--dry-run] [--github-output <path>]
smoo release publish --bump <auto|patch|minor|major|prerelease> [--dry-run]
smoo release retag-unpublished <tag...> [--to <ref>] [--push] [--dispatch] [--remote <remote>] [--branch <branch>] [--dry-run]
smoo release bootstrap-npm-packages [--dry-run] [--skip-login] [--package <name...>]
smoo release trust-publisher [--bootstrap] [--dry-run] [--skip-login] [--package <name...>]
smoo github-ci nx-smart --target <target> [--name <check-name>] [--step <number>] [--mode <auto|affected|run-many>] [--stage <stage>]
smoo github-ci nx-run-many --targets <targets> [--projects <projects>] [--collect-outputs <directory>]
smoo github-ci nx-deploy [--stage <stage>] [--mode <auto|affected|run-many>] [--select-tag <tag>] [--verify]
smoo github-ci apply-outputs <directories...> --source-sha <sha>
smoo github-ci dispatch-workflow --workflow <workflow> --ref <ref>
smoo github-ci ensure-pull-request --head <branch> --base <branch> --title <title> --body <body>Initialization
smoo monorepo init is the fix-first command. It mutates the repository toward the SmoothBricks convention instead of
only reporting drift.
It currently:
- Updates managed CI, release, hook, and formatting files. The publish workflow is only written when the repo has owned release packages.
- Ensures the local
tooling/smoosource shim is executable when present. - Synchronizes root runtime versions inside devenv, or when
--sync-runtimeis passed. - Applies safe publish metadata defaults to
npm:publicpackages without inferring package ownership. - Normalizes internal workspace dependency ranges to
workspace:*. - Rewrites safe package scripts in packages with workspace dependencies so developer commands like
bun run testandbun run devstay available while delegating through Nx targets. smoo monorepo validate --fixcreates/updatestooling/package.json, keeps@smoothbricks/clithere instead of the root workspace package, and fills required workspace/devenv tool declarations.- Runs [
sherif --fix --select highest][sherif] for broad monorepo package hygiene. - Normalizes conditional export ordering so
typescomes first anddefaultcomes last. - Adds
srcto packagefileswhen development-only exports intentionally point at source files.
The workspace dependency rule is generic. smoo does not know about individual package names such as eslint-stdout.
For every root or workspace package.json, if a dependency name matches an actual package in the same workspace, smoo
rewrites that range to workspace:*.
Packages with internal workspace dependencies also need Nx-aware scripts so dependent builds run before local commands.
For safe build, test, typecheck, benchmark, dev, and preview commands, smoo monorepo validate --fix moves the real
command into package.json nx.targets.<target>.options.command, sets cwd to {projectRoot}, and replaces the
script with an nx run <project>:<target> alias. Continuous commands such as astro dev, vite dev, and previews get
an explicit output style on the alias and continuous: true on the Nx target. Astro/Vite dev servers use
--outputStyle=dynamic-legacy; other continuous targets use --outputStyle=stream. Simple leading environment
assignments are moved into nx.targets.<target>.options.env so commands such as
NODE_OPTIONS='--import=extensionless/register' astro dev remain shell-independent.
The rewrite is intentionally conservative. smoo does not rewrite deploy, database, release, sync, subtree, publish, or
pack scripts, and it rejects Nx target commands that recurse through package scripts such as bun run test. The reason
is dependency correctness without hiding unsafe operational commands behind generated Nx targets: workspace-dependent
packages should get ^build ordering for ordinary development commands, while publishing and deployment stay explicit.
smoo monorepo init --runtime-only only synchronizes root runtime versions. It is used from direnv setup so
packageManager, engines.node, and @types/node stay aligned with the active devenv shell without duplicating that
policy in the direnv script.
Bun types are also a root runtime policy. The root @types/bun version follows the exact packageManager Bun version,
while package manifests are not forced to depend on Bun just because a test tsconfig opts into Bun globals. If a package
does explicitly declare @types/bun, sherif can keep duplicate declarations consistent, but smoo owns the semantic
root [email protected] to @types/bun x.y.z relationship.
LMAO Test Tracing
smoo monorepo setup-test-tracing configures LMAO-backed Bun test tracing for workspace packages. It is a bulk wrapper
around the @smoothbricks/nx-plugin:bun-test-tracing generator, so the Nx plugin remains the single source of truth for
the files written.
Configure every workspace package:
smoo monorepo setup-test-tracing --allConfigure selected packages by Nx project name, package name, or package root:
smoo monorepo setup-test-tracing --projects cli,@smoothbricks/lmao,packages/nx-pluginThe command infers the op context module from each package's package.json name, assumes an opContext named export,
and imports defineTestTracer from @smoothbricks/lmao/testing/bun. Override those defaults when a repository uses a
different convention:
smoo monorepo setup-test-tracing --projects my-lib --op-context-export myOpContext
smoo monorepo setup-test-tracing --projects my-lib --tracer-module @scope/testing/bunUse --dry-run to print the nx g @smoothbricks/nx-plugin:bun-test-tracing ... invocations without writing files.
After setup, run smoo monorepo validate --fix to apply the broader SmoothBricks monorepo policy.
Validation
smoo monorepo validate is the read-only gate. It should pass in local shells and CI after packages have been built.
It checks:
- Managed file drift.
- Root package policy.
- Root Bun type version matches the Bun package manager version.
- Tooling policy: root
package.jsonowns workspace-level tools likenx,tooling/package.jsonownssmoo, andtooling/direnv/devenv.nixowns shell-provided tools likebun,git-format-staged, andfmt. - Nx release policy, including project package release tags, project-level GitHub Release changelogs, and the temporary Bun lockfile versionActions hook.
bun.lockworkspace versions match package manifests.- Public package tag policy.
- Public package metadata.
- Workspace dependency ranges.
- Workspace-dependent package scripts delegate safe commands through Nx targets without recursive script runners.
- Nx target conventions and inferred-task setup.
sherifpackage hygiene, with warnings treated as validation failures.- Packed public package artifacts with
publint. - Packed public package type resolution with the
attwCLI.
The packed-package checks validate what npm users will install, not only the source tree. smoo packs each
npm:public package with Bun, runs publint on the tarball, then runs attw on the same
tarball.
The attw check uses the node16 profile and ignores the CJS-to-ESM warning. SmoothBricks
packages are ESM-first, so CommonJS consumers can use dynamic import. Node 10-only subpath failures are
intentionally ignored because Node.js 10 is not part of the supported package contract.
smoo monorepo validate --only-if-new-workspace-package first checks the staged git diff for newly added workspace
package manifests. If none are staged, it exits successfully without running the full validator. The generated
pre-commit hook uses this mode so adding a package rechecks conditional managed files, including whether the publish
workflow is now required, without making every commit pay for full validation.
Publishable Packages
Publishability is declared with an Nx tag:
{
"nx": {
"tags": ["npm:public"]
}
}This tag is the source of truth for public npm metadata and publish artifact validation. Release selection adds one more
convention: a package is released by the current repository only when its repository.url exactly matches the root
package.json repository.url. Equivalent-but-different spellings, such as git+https vs SSH for the same GitHub
repo, fail validation because ownership should be explicit and visually obvious. This lets a workspace mirror public
packages from another repository without publishing them from the mirror.
Rules:
npm:publicpackages must not beprivate: true.private: truepackages must not havenpm:public.- Public packages must define license metadata.
- Public packages must publish with
publishConfig.access = "public". - Public packages must define
repository.type,repository.url, andrepository.directory. - Public packages must define
files. - Public library packages must define
types. - Public packages must define either
exportsorbin.
Owned public packages may inherit the root license when the root license is not UNLICENSED. Mirrored public packages
must carry their own license. smoo monorepo init does not copy the root repository.url into packages; a missing
package repository.url is a validation failure so new packages must consciously choose whether they are owned by the
current repository or mirrored from another one. Init still sets publishConfig.access = "public", repository type,
repository directory, export ordering, and source-file publish entries when those can be derived safely.
smoo monorepo list-release-packages prints the comma-separated Nx project names for packages that are both
npm:public and owned by the current repository. Release commands, trusted-publisher setup, and the managed publish
workflow use this owned release package list instead of every public package in the workspace. smoo keeps both names in
release metadata: Nx commands, GitHub Release tags, and git release tags use projectName, while npm publish checks and
tarball validation use the real package name.
For GitHub Actions, smoo monorepo list-release-packages --fail-empty --github-output "$GITHUB_OUTPUT" appends the
projects=<nx-project-list> output expected by the managed publish workflow and fails with a clear error when no owned
release packages exist.
smoo release npm-status shows whether each owned release package's current name@version already exists on npm. It is
an npm registry check, not a full release workflow status check.
smoo release version --bump auto first selects direct release candidates, then delegates versioning to Nx. Direct
candidates are owned public packages with package-local changes that can affect published users: files matched by the
package's resolved Nx build/production inputs, packaged assets listed in package.json files, package metadata
docs such as README/LICENSE/CHANGELOG, or user-visible package.json fields such as exports, bin, types,
dependencies, peerDependencies, and publishConfig. Test-only and local automation changes such as scripts, nx,
devDependencies, and tsconfig.test.json do not select a package by themselves.
Downstream dependency bumps are intentionally left to Nx release. If package A is selected and bumped, Nx may also bump
public package B when B depends on A, even when B has no direct file changes. smoo should not pre-expand direct
candidates to downstream dependents because that would duplicate Nx's dependency graph and can over-select packages.
Nx Conventions
smoo keeps Nx target names predictable and separates tool work from aggregate workflows.
@smoothbricks/nx-plugin owns transformer-aware TypeScript targets because this workspace compiles through ttsc.
Configuring @nx/js/typescript would run tsc/tsgo directly and bypass the Typia and LMAO transformers. A package
tsconfig.lib.json therefore produces the concrete tsc-js target from the SmoothBricks plugin. The same plugin also
infers Bun test typechecking, Cargo workspace targets, and aggregate targets.
Concrete targets use {tool}-{output} names and describe the tool that runs and the artifact or purpose it produces:
tsc-jscomes from@smoothbricks/nx-pluginand runsttscfor package JavaScript/declaration output.- Packages that run
bun testmust havetsconfig.test.json. Bun executes tests without typechecking, so smoo creates a no-emittypecheck-teststarget from that config and wires it into validation. Other test runners may own their own typecheck path. - Test tsconfigs are validation configs, not TypeScript build-mode projects. They must use
noEmit, must not setcomposite: true, and package roottsconfig.jsonmust not reference./tsconfig.test.json. The inferredtypecheck-teststarget runsttsc --noEmit -p tsconfig.test.jsonafterbuildinstead. - A neighboring workspace-root
Cargo.tomlprovides Cargo test, lint, mutation, and benchmark targets. Workspaces withcdylibmember crates also receive the cacheablecargo-wasmoutput target. buildis an aggregate. It exists only when there is at least one concrete build target such astsc-js,tsdown-js, or another tool-output target, and it depends on output-family wildcards such as*-js,*-web,*-html,*-css,*-ios,*-android,*-native,*-napi,*-bun, and*-wasminstead of duplicating commands.lintis an aggregate validation target. It is not a formatting target.
The root @typescript/native dependency follows TypeScript's documented side-by-side pattern: it aliases TypeScript 7
and supplies the native compiler used by ttsc. Because ttsc resolves only the unscoped package by default, the
managed devenv shell sets TTSC_TSGO_BINARY to node_modules/@typescript/native/bin/tsc; the GitHub setup action
persists that absolute path through GITHUB_ENV. Nx and other JavaScript tooling still require the full TypeScript
compiler API, so workspace typescript stays on TypeScript 6. After install, setup-environment forces Bun's shared
.bun/node_modules/typescript hoist onto that API package so Nx does not load @typescript/native (TS7) via the store
(oven-sh/bun#33834). Both dependencies and the environment binding are
required: TypeScript 7 under the unscoped name breaks Nx API calls such as readConfigFile.
Explicit Nx target names must not contain :. Nx already uses colon syntax at the CLI boundary:
project:target:configuration. Allowing target names like build:wasm makes command parsing and package-script aliases
look like configurations, and it prevents a clean split between concrete tool-output targets and aggregate targets.
Use tool-output names for concrete targets, such as tsc-js, tsdown-js, and cargo-wasm. Use build and lint only
as aggregate targets. Package scripts may still use developer-friendly colon names, for example build:wasm, but those
scripts should delegate to unambiguous Nx targets such as nx run pkg:cargo-wasm.
Package Structure
Workspace packages keep all TypeScript sources — and especially all test files — under a single root: src/.
packages/<name>/
src/
foo.ts
foo.test.ts # unit tests: colocated with the module they defend
__tests__/ # cross-module/integration TS tests, still under src/
crates/*/tests/ # Rust integration tests (cargo-owned convention, unaffected)
scripts/ # non-shipped tooling; never *.test.ts
dist/, target/ # generated output, never scannedThe test-location rule is not tidiness; it is what makes the generated tooling provably cover every test. The convention
is one root, not one path: tsconfig.test.json includes are generated as src/** patterns, and the bounded
bun test targets run with cwd <package>/src — both because bun test <arg> treats the argument as a filter over a
scan rooted at the cwd (scanning from the package root would walk a Rust package's entire cargo target/ tree, tens of
seconds per run), and because a second test root would have to be threaded through every one of those consumers forever.
A test file outside src/ is therefore neither typechecked nor executed, silently. smoo monorepo validate fails on
any *.test.ts / *.spec.ts(x) outside src/ so that gap cannot reappear.
Within the single root, separation still exists where it matters: unit tests sit next to their modules, integration
tests live in src/__tests__/, and Rust crates keep cargo's own tests/ directories, which the rule deliberately
ignores.
Managed Files
smoo monorepo update writes the managed files into a repository.
Managed files include:
tooling/git-hooks/git-format-staged.yml- Git hook scripts under
tooling/git-hooks - direnv/GitHub Actions bootstrap scripts under
tooling/direnv - GitHub Actions workflows under
.github/workflows - Local composite GitHub Actions under
.github/actions
When a managed target is a symlink, smoo leaves it alone. SmoothBricks uses symlinks back to packages/cli/managed so
changes to the CLI package are tested immediately. Downstream repos receive ordinary committed copies.
The publish workflow is conditional. Repositories with no owned release packages skip .github/workflows/publish.yml in
init, check, and diff; adding a new owned package makes the workflow required on the next validation run.
Use:
smoo monorepo update
smoo monorepo check
smoo monorepo diffcheck fails when a managed file is missing or stale. diff reports drift without writing files.
The generated publish workflow is canonical Prettier YAML. Running the repository formatter over
.github/workflows/publish.yml is byte-stable and does not create managed-file drift.
Formatting And Git Hooks
The root lint:fix script runs git-format-staged with
--config tooling/git-hooks/git-format-staged.yml --unstaged. The formatter config intentionally excludes bun.lock.
The generated pre-commit hook runs the same formatter path from the repository root with tooling, node_modules/.bin,
and the devenv profile on PATH.
After formatting, the hook runs smoo monorepo validate --fail-fast --only-if-new-workspace-package. This keeps normal
commits fast while still catching incomplete package setup and conditional managed-file drift when a new workspace
package manifest is staged.
The generated commit-msg hook delegates conventional commit validation to:
smoo monorepo validate-commit-msg --fix <commit-msg-file>This keeps hook behavior consistent with CI and avoids duplicating commit message parsing in shell. With --fix, smoo
wraps prose body paragraphs through fmt -w 72 while preserving fenced code blocks, quoted markdown, indented blocks,
bullets, trailers, URLs, and comment lines.
The generated pre-push hook runs only on macOS. Linux nx lint already compiles the Linux cfg(target_os) arm; Darwin
does not. The hook runs nx run-many -t cargo-lint-cross. Nx caches that target on the Cargo inputs, so a hit is a
prior real Linux clippy and never enters linux-cross. A miss falls through to bun run check:linux, which is
tooling/devenv --quiet -P linux-cross shell -- around that same Nx target and writes the cache. git push --no-verify
skips the hook.
Conventional commit scopes should use Nx project names. For packages in the same npm scope as the root package, smoo
requires package.json nx.name to be the unscoped package name, such as cli for @smoothbricks/cli, so subjects
like fix(cli): repair release notes map cleanly to Nx Release.
GitHub Actions
The generated GitHub Actions workflows keep readable YAML and named top-level steps, while larger logic lives in
smoo commands, post-checkout composite actions, or the small pre-smoo bootstrap script. Checkout stays inline in each
workflow because repository-local composite actions do not exist until actions/checkout has populated the working
tree.
CI uses explicit lint, test, and build phases. The publish workflow does the same after versioning so GitHub output stays readable and validation happens on the exact release commit.
CI status deeplinks depend on GitHub Actions' top-level job step anchors. The generated CI workflow keeps # Step N
comments next to each top-level step, and the smoo github-ci nx-smart --step <number> values for lint, test, and build
must stay synchronized with those comments. Composite action internals do not change the top-level step numbers.
Managed CI setup is split across local composite actions:
setup-devenvdetects the runner kind first. An ephemeral runner installs a single-user Nix, restores/nixitself from the Actions cache — store paths and the Nix database, as files, so nothing is imported — enables Cachix, installs devenv atdevenv.lock's rev, restoresnode_modulesand the ttsc plugins, and builds the shell. A host-nix runner (NIX_REMOTE=daemonwith/var/cache/ci) skips the install and every store cache: it already has the store and keeps its caches on the shared bind.save-nix-devenvruns underalways()and saves the.devenv/.direnveval-cache segment when setup missed it and the shell producednix-eval-cache.db. The store cache saves itself insetup-devenv's post phase, which collects garbage down to the live closure before uploading.cache-nix-devenvis the shared restore/save primitive for the.devenv/.direnvsegment.
The cache split is intentional. The store segment is large and keyed by the expensive shell closure inputs
(devenv.yaml, devenv.nix, devenv.lock), and it carries store content only — never the Nix profiles, which exist to
be gcroots: restoring a previous run's copies over a freshly installed Nix de-roots the running Nix binary, and the post
phase then collects it. The .devenv/.direnv segment is small, but it holds absolute /nix/store pointers, so it
restores on every ephemeral runner and devenv checks that the shell's derivation and output still exist on an eval-cache
hit, re-evaluating when garbage collection removed them.
The bootstrap script is intentionally small. It only handles work required before smoo can run in GitHub Actions:
- Install
devenv, held todevenv.lock's rev on an ephemeral runner. - Build the devenv shell and add repo-local tooling to
GITHUB_PATH.
Deploy configuration (package.json → smoo.github)
pushBranches: the first entry is the branch whose pushes deploy the staging stage (defaultmain).environments.staging/environments.production: GitHub Environments put on the validate + e2e jobs and on the production-on-push job respectively. The staging Environment goes on Validate for every run of a deploying repo, pull requests included, so a staging Environment with required reviewers would gate every pull request's Validate.deploySecrets: extra secrets for deploy steps, as a map of env var name → repository secret name, renderedNAME: ${{ secrets.SECRET }}(GitHub forbidsGITHUB_-prefixed secret names, so the two may differ). The keysCLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_IDreplace the default Cloudflare mapping.e2eSecrets: the same map shape for the e2e-deployment step only.previewUrls: required URL templates for pull-request stages ({stage}is replaced, and required in every template). The first one becomes the GitHub deployment URL, all are listed in the step summary. There is no default: a pull-request deploy with selected projects and nopreviewUrlsfails instead of inventing a hostname, while a stage with no selected projects still skips quietly.SMOO_PREVIEW_ZONEis no longer read; move its host intopreviewUrls(for example, a zone that used to buildhttps://app.<stage>.<zone>becomes["https://app.{stage}.<zone>"]with the zone's real hostname in place of<zone>).
Cloudflare deploys and cleanups need CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN. The token needs Workers
Scripts, Workers KV, R2, and D1 write, plus Zone DNS and Workers Routes write for preview hostnames. A token without D1
access makes cleanup refuse before it deletes anything rather than half-clean a stage.
A wrong value type anywhere in smoo.github fails smoo monorepo update and smoo github-ci nx-deploy with the
offending path, rather than silently falling back to the defaults.
Tag a project production-push-deploy-target to have it deployed to production by a generated deploy-production job
that runs after Validate and the e2e job succeed on a push to the staging push branch
(smoo github-ci nx-deploy --stage production --select-tag production-push-deploy-target). The project must also be
stage-derived — carry stage-deploy-target or deploy through smoo wrangler deploy-stage — otherwise --select-tag
finds nothing and the job logs No run-many deploy projects; skipping production.
Ordering one deploy after another
When a project's deploy calls into what another project deploys — a site that signs in to its stage's backend — say so with an ordinary Nx edge on the deploying project:
{ "nx": { "targets": { "deploy": { "dependsOn": ["...", "app-backend:deploy"] } } } }Nx then orders it, at any depth: site:deploy after app-backend:deploy after db:deploy all resolve inside the one
nx run-many that smoo github-ci nx-deploy already issues, and nx deploy site --stage=… run by hand gets the same
order. Keep the leading "...": it expands the deploy-build edge the plugin infers (below), which a bare list would
drop.
What makes the edge safe is that a deploy is never cached and always cheap when there is nothing to do:
@smoothbricks/nx-plugingives every declareddeploytargetcache: false. An Nx cache hit on a deploy means "we once uploaded this hash", which a rollback silently falsifies — the workspace is unchanged, so the hash is unchanged, so a cached deploy would report success while the previous version keeps serving.smoo wrangler deploy-stagereads live state first. If the version tagged with this task's hash is already the one serving 100% of traffic, it returnsremote-cache-hitafter two API calls: no upload, no migration, no traffic shift.- The expensive, purely file-derived half belongs in a sibling
deploy-buildtarget (build the artifact, register it, refresh what the build needs). The plugin gives that onecache: trueand makesdeploydepend on it, so a redundant deploy costs two API calls rather than a rebuild. - A deploy does not finish when Cloudflare accepts the traffic shift; it finishes when the new version is the one being
served.
deploy-stagepollswrangler deployments statusuntil the tag it activated is live, and fails with what it expected and what it saw if that never happens. Pass--version-endpoint <url>to also require an endpoint served by the worker — whose trimmed response body is the running version tag — to answer with it. Without that wait, an edge onto a deploy orders nothing: the step returns while the edge still serves the old code.
The policy caveat, plainly. A cross-project edge is part of the graph, not of the selection: a run that selects only
the dependent project will still evaluate the dependency's deploy. When the dependency is already at that hash, that
evaluation is a no-op — two API calls. But it is not inert: if the dependency is intentionally behind (its production
deploy is being held back, say), a run that selects only the dependent project will advance the dependency to the hash
the current workspace produces. If you need a project to be deployable without touching what it depends on, do not add
the edge; sequence those two deploys as separate CI jobs instead.
smoo wrangler deployed-version --stage <stage> prints the tag serving all traffic for the project's worker on that
stage, so an operator can check the same fact the deploy checks. It caches its answer briefly under Nx's workspace data
directory; that cache is a convenience for repeated queries only. Nothing that decides whether to deploy reads it, and
it refuses — rather than answering unknown — without credentials, on an API error, or while traffic is split between
two versions.
Pushes to the staging push branch queue behind a running workflow instead of canceling it, so a newer push never cancels
a production deployment mid-flight. Pull requests and other branches keep canceling superseded runs. The e2e and
production jobs repeat the Cargo credential and sibling-source preflight before SetupDevenv, so their --step anchors
shift with the configuration instead of staying fixed.
Private dependency configuration (package.json → smoo.github.cargoCredentials)
The private git origins Cargo fetches from, and the secret that reads each one:
{
"smoo": {
"github": {
"cargoCredentials": {
"gitOrigins": [
{
"origin": "https://git.example.net",
"tokenEnv": "SOURCE_READ_TOKEN",
"internalMirror": "http://10.89.0.1:3000",
"sshOrigins": ["ssh://[email protected]:2223/", "ssh://forge.example.net:2223/"]
}
],
"registryTokenEnvs": ["CARGO_REGISTRIES_EXAMPLE_TOKEN"]
}
}
}
}origin: the credential-free https origin, andtokenEnvthe repository secret that reads it. The generated credential helper answers for that host alone, reading the token from the environment when git calls it.internalMirror: the address managed runners reach the same forge at, such as a container-bridge address. Managed CI rewrites the origin prefix onto it withurl.<mirror>.insteadOfand answers the same credential for the mirror's host, because git hands helpers the rewritten URL.sshOrigins: the SSH spellings of that same forge, as a lockfile pins them (Cargo.tomlgit dependencies, uv sources). Each one gets its owninsteadOfline onto the mirror. git matchesinsteadOfvalues as literal URL prefixes and derives no spelling from another, so a forge pinned asssh://forgejo@host:2223/and asssh://host:2223/needs both declared; a runner holding only the mirror's read token and no SSH key would otherwise fetch nothing. The declarations stay credential-free — the rewrite happens before transport, so an SSH pin nobody rewrote fails loudly instead of collecting a token — and each requiresinternalMirror, since an SSH spelling is a rewrite source and nothing else.registryTokenEnvs:CARGO_REGISTRIES_<NAME>_TOKENsecrets Cargo's own credential provider reads for private registries.
Remote cache configuration (package.json → smoo.remoteCache)
One Nx self-hosted remote cache shared by every runner and every developer shell:
{
"smoo": {
"remoteCache": {
"server": "https://nx-cache.example.net",
"internalServer": "http://10.89.0.1:8765",
"tokenSecret": "NX_REMOTE_CACHE_TOKEN"
}
}
}server: the origin every developer machine reaches. Credential-free, no path, and no trailing slash — Nx appends/v1/cache/<hash>, so a trailing slash asks for a doubled-slash route that answers 404 forever. Such a declaration is refused at render time rather than trimmed, because managed CI and the developer shell read the same field.internalServer: the origin managed runners reach instead, such as a container-bridge address. Same declaration as a git origin'sinternalMirror— it says this repository's runners sit inside that network — so every generated job takes it while shells outside keepserver. Omitted means CI usesservertoo.tokenSecret: the repository secret holding the cache token, and the variable name a developer shell resolves locally (an ambient value, or asmoo.secretsentry). A read-only token is the honest choice for an untrusted context: it reads the cache and cannot publish into it.
Both generated workflows put NX_SELF_HOSTED_REMOTE_CACHE_SERVER and NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN in
every job env that runs Nx. The pair is emitted whole or not at all: Nx enables its cache on a nonempty server alone and
accepts only 200 or 404 from it, so a server it cannot authenticate to fails every task on 401 instead of missing
quietly. That is also why a declared cache puts the same-repository gate on Validate that a private dependency install
does — a fork pull request receives no secrets, and a job with half the pair would fail everything.
Developer shells get the pair from the managed tooling/direnv/secret-references.ts, which the managed devenv
enterShell runs and evals: it prints the two exports when the declared token has a value, prints nothing when it has
none (with the reason on stderr), and never replaces a server the environment already carries, so a CI job keeps the
internal address its own runners reach.
tokenSecret may name a smoo.secrets entry, and then it is the one declared secret that never blocks an install: a
cache is an optimization, so an unreachable secret provider costs a stderr line where every other declared secret would
refuse the install outright. op read being unavailable in a sandboxed workspace therefore loses the cache, not the
shell.
Releases
Release commands wrap Nx Release but keep SmoothBricks policy in one place.
Versioning:
--bump autofirst filters owned release packages to package-local candidates, then lets Nx Release derive the semver bump from Conventional Commits. A tagged package is an auto candidate only when files under its package root changed since its currentprojectName@versionrelease tag. An untagged package is an auto candidate only when its package root has git history and its current version is stable. Root-only changes, workflow edits, lockfile-only churn, untagged next-prerelease preparation commits, and other workspace-global changes may still affect Nx tasks, but they do not make unrelated package artifacts releasable.--bump patch|minor|major|prereleaseforces the release specifier only; it never widens the release set. Package selection comes from--projects: blank selects the package-local changed set (the same filter--bump autouses), whatever the bump mode is.--projects <a,b,...>releases exactly those owned Nx projects without change detection, and--projects allis the deliberate whole-fleet opt-in that versions every owned release package.- Release packages are discovered from
npm:publicpackages whoserepository.urlexactly matches the root package. - Nx Release config must use
currentVersionResolver: "git-tag"withfallbackCurrentVersionResolver: "disk". Conventional-commit versioning requires git tags as the primary source, while the disk fallback supports initial releases before package tags exist. - Nx Release config must use
versionActions: "@smoothbricks/cli/nx-version-actions". This wraps Nx's JS version actions and temporarily syncsbun.lockworkspace versions after Nx runsbun install --lockfile-only. - Same-org scoped packages must define short
package.jsonnx.namevalues, for example@smoothbricks/moneyuses"nx": { "name": "money" }. This lets Nx Release understand commit scopes likefix(money): ...without requiring the npm org in every commit subject. - Nx project names and npm package names are different release identities. Nx project filters, workflow
projects=outputs, build/lint/test validation, project changelog lookup, GitHub Release tags, and durable git release tags useprojectName. npm publish checks and tarball validation use packagename. - Nx Release
preVersionCommandis intentionally not used. smoo builds exactly the packages that still need npm publish immediately before packing them, while the managed workflow separately builds, lints, tests, and validates newly created release commits. smoo release repair-pendingruns before the normal publish flow. It repairs older remote release tags whose npm package version or GitHub Release is missing, while leaving the currentHEADrelease target toversionandpublish.smoo release versionselects the current release target before validation by running Nx Release versioning. npm registry state is not used to decide whether versioning should run. IfHEADis already a release target, versioning returnsmode=noneand leaves idempotent completion tosmoo release publish.smoo release version --github-output "$GITHUB_OUTPUT"appendsmode=new|noneandprojects=<comma-list>. The managed publish workflow usesmode != "none"to build, lint, test, and validate exactly the commit thatsmoo release publishwill publish.projectsis a comma-separated Nx project-name list, not an npm package-name list. The validation and publish step names include the selected mode so the GitHub Actions run shows whether it is creating a new release or recording a no-op.- An explicit bump with a non-empty selection is mandatory progress when
HEADis not already a release target: after pending releases are repaired,bump=patch|minor|major|prereleasemust make Nx create a new release commit, and smoo fails if Nx returns without movingHEAD. Every bump mode may no-op withmode=nonewhen the selection is empty: no package-local changes for blank--projects, or no owned release packages at all for--projects all. --dry-runpreviews versioning and completion without pushing refs, publishing npm packages, or writing GitHub Releases.- The pack path maps unpublished
-nextlock entries to the last stable tag becausebun pm packresolvesworkspace:*frombun.lock. Supported Buns keep the lockfile fresh on install (18906, 20477, 20829), so no staleness repair remains. - Package release tags must use the Nx project name and version, for example
[email protected]. smoo derives release package/version pairs from that tag shape and maps project names back to npm package names before checking npm state. smoo release retag-unpublished <tag...>is a break-glass recovery command for the case where Nx already committed a version bump but npm publish failed before the package version became durable. It moves exact owned release tags toHEADby default without bumping package manifests again. It refuses to move a tag whenpackage@versionalready exists on npm, when the GitHub Release exists, or when the target ref's package manifest does not contain the tagged version. Pass--pushto update remote tags with--force-with-lease; pass--dispatchto also startpublish.ymlwithbump=auto. Dispatch validates that the target ref is already the remote branch head so the workflow will publish the same commit that was retagged.
Repair Process
repair-pending is tag-driven, not history-driven. It starts from fetched remote release tags because those tags are
the durable record that a package version was selected for release. It only checks npm and GitHub Release state to
decide which tags still need work; it does not walk normal commits looking for release-shaped changes.
- Collect owned release tags from the fetched remote tag set, sorted newest-first by annotated tag
creatordate. Only tags matching owned Nx project release names are considered, and each tag is peeled to the commit it releases. - Classify each owned release tag before grouping by commit. A tag needs npm repair when
package@versionis missing from npm, and it needs GitHub repair when the GitHub Release for that tag is missing. Tags needing neither are filtered out immediately. - Group only repair-needed tags by peeled commit. Empty commits disappear because their tags were already complete.
Exclude
HEADbecause the current release target is handled bysmoo release versionandsmoo release publish, not by the older-release repair loop. - Sort the remaining repair commits oldest-to-newest. Only after this sorted non-HEAD repair list exists does smoo start checking out commits.
- For each repair commit, check out the commit once and load that checkout's direnv environment once. If any grouped
tag still needs npm publish, run
nx run-many -t build --projects=<comma-separated npm-missing Nx projects>once, then publish those packages using the npm dist-tag implied by each package version. If the commit only needs GitHub Releases, skip the build. Finally, create the missing GitHub Releases for the grouped tags that need them.
Pending release state should be a suffix of the release-target timeline because repair-pending runs before every
publish. Once a complete release target is reached, older targets are assumed complete; an observed gap in repair state
violates the workflow invariant and should fail loudly instead of silently repairing history out of order.
Publishing:
prereleasepublishes with npm dist-tagnext.- Stable bumps publish with npm dist-tag
latest. smoo release publishpushes missing branch/tag refs, publishes missing npm versions, creates or updates GitHub Releases, and writes a GitHub Step Summary. Already published npm versions are skipped, so reruns after auth or network failures retry only the package versions npm does not have yet.- After a successful non-dry-run stable release at the branch tip,
smoo release publishruns an untaggedprerelease --preid nextversion bump for the released stable packages and pushes that branch commit. This prepares the codebase for the next development prerelease without creating prerelease release tags or publishing npm packages. - npm registry state gates publish idempotency only. It decides which already-versioned package tarballs still need to be published during a real release retry, not whether versioning should run or whether the workflow has a release to publish.
- Before npm publish, smoo runs
nx run-many -t build --projects=<comma-separated npm-missing Nx projects>for exactly the packages whosename@versionis not on npm yet. Nx cache makes this cheap when the managed workflow already built the same projects, and it keeps reruns self-sufficient when repairing a previously selectedHEADrelease target. - Publish uses
bun pm packto create package tarballs, then publishes those tarballs with latest npm CLI and--provenance. Each package uses the npm dist-tag implied by its own version (nextfor prereleases,latestfor stable versions). Bun pack resolves internalworkspace:*dependency ranges to real versions in the tarball manifest; smoo fails before publish if a packed manifest still containsworkspace:or if an internal dependency does not match the current workspace package version. - npmjs accepts
--provenanceonly from GitHub-hosted runners (RUNNER_ENVIRONMENT=github-hosted). On GitHub Actions, smoo refuses a public provenance publish whenRUNNER_ENVIRONMENT=self-hostedbefore calling npm, so the failure names runner policy instead of trusted-publishing credentials. Private Forgejo publication does not use provenance. Local public publishes (noGITHUB_ACTIONS) are unchanged. - npm CLI owns publish authentication. Packages use trusted publishing with GitHub
Actions OIDC from the workflow's
id-token: writepermission. Package names must exist on npm before CI publish runs; usesmoo release trust-publisher --bootstraplocally to publish0.0.0-bootstrap.0under thebootstrapdist-tag for new package names before configuring trust. smoo release bootstrap-npm-packagesscans ownednpm:publicrelease packages missing from npm, runsnpm login --auth-type=webunless--skip-loginis passed, and publishes a minimal placeholder package with--access public --tag bootstrap. It supports--dry-runand--package <name...>for targeted bootstraps.smoo release trust-publisherconfigures npm trusted publishing for every owned release package. It uses the rootpackage.jsonrepository.urlas the GitHubowner/repo, usespublish.ymlas the trusted workflow, grants that workflow npm's--allow-publishpermission, and runsnpm trustfrom PATH, where the devenv-pinned Node supplies an npm new enough for the feature. It does not pre-login:npm trust listandnpm trust githubown normal authentication so npm can offer the 5-minute trust/publish challenge bypass. Ifnpm trust listdenies access, smoo reports the active npm identity and package owners, opensnpm login --auth-type=webonce so the operator can switch accounts, then retries the lookup. A second denial fails instead of looping. Pass--package <name...>to target specific owned packages. Pass--bootstrapto create missing npm package names first, then configure trusted publishing in the same command. With--bootstrap,--skip-loginonly skips the placeholder publish login. Existing matching trusted publishers are skipped vianpm trust list <package> --json.
GitHub Releases:
smoo release publishdelegates tonx release changelogonce per owned package whose current package release tag is atHEAD, passing that package's version explicitly for independent releases after npm publish succeeds.- Nx project changelogs are configured to create or update GitHub Releases, not local changelog files.
- Generated release notes are package-scoped Conventional Commit changelogs. Init defaults author rendering and GitHub username lookup on, while validation allows repos to override those render options.
The release flow is designed to be rerun after partial failure. Nx owns local version/tag behavior, while smoo derives durable completion state from the remote branch, release tags, npm registry versions, and GitHub Releases. Repeated Publish runs converge without self-spawning another workflow run.
Wrangler Commands
smoo wrangler deploy-stage --stage <stage> [--config <path>]
Without --config, deploys ./wrangler.toml with --env <stage>; a prN stage is derived from [env.staging].
With --config <path> the target is a build-generated, env-block-free wrangler.json (what the Cloudflare Vite and
Astro adapters emit). staging and production deploy it as-is, with no --env flag. A prN stage treats it as the
staging template and derives a copy beside it: worker name (<base>-prN from a -staging name), routes and vars by
hostname label (hosts without a staging label are pinned to staging and dropped; a template whose routes are all
pinned is refused, since the stage would deploy unrouted), KV namespaces created by title, R2 buckets, D1 databases
created by name with their migrations applied, services bindings and rate limits. Cleanup (cleanup-pr) removes every
resource carrying the prN segment, D1 included.
--configdeploys ignoreCLOUDFLARE_ENV. There is no--envflag for a flat config, so wrangler would otherwise fall back to that variable and rename the worker after it.- The secrets manifest (
.dev.vars.example) and the temporary secrets file come from the working directory, not from beside the--configfile. - Migrations run only for the D1 bindings that declare a
migrations_dir. - For a
prNstage, an R2 bucket or D1 database whose name has no exactstagingsegment is refused, before any Cloudflare resource is created: reusing the name verbatim would share staging's data with the pull request. - A non-wildcard route gets no DNS record from this command; the stage's wildcard record must already exist.
- D1 migrations are auto-confirmed: the command captures wrangler's output, so wrangler sees a non-interactive session and answers its own "apply migrations?" prompt with yes. Point it only at a stage you mean to migrate.
Why This Shape
The important design goal is one source of truth per convention:
- Nx
npm:publictags decide what has a public npm package contract. - Matching root/package
repository.urlvalues decide which public packages are released by the current repo. - Managed files decide what generated CI and hooks should look like.
- Root package metadata provides defaults only for owned public packages.
- Actual workspace package names decide which dependency ranges become
workspace:*. - Package manifests decide Bun lockfile workspace versions; the pre-publish pack maps unpublished
-nextentries to the last stable tag. sherifhandles broad package hygiene.publintandattwvalidate real packed artifacts.
This keeps smoo small where external tools already do the job, but keeps SmoothBricks-specific policy native where
generic tools do not know the repo contract. In particular, sherif is useful for package hygiene, but it does not know
SmoothBricks publish metadata, release tags, generated workflow files, or Nx release policy. Those remain smoo
conventions.
Local Verification
Typical verification after changing smoo:
nx typecheck @smoothbricks/cli
nx lint @smoothbricks/cli
smoo monorepo validate