Skip to content

Workspace Packages and Publishing

Date: 2026-08-24 Context: shared code in the Arda frontend currently costs a new repository, and the application’s single version and changelog cannot describe a package released from alongside it. This proposes the smallest structure that fixes both, and records what was rejected on the way.


Hoisting makes an undeclared dependency invisible. node_modules is hoisted to the repository root, so every dependency the root installs resolves from every file in every package. A package can import a library it never declared and the build succeeds, the type check succeeds, and every test passes — they all read the same disk. It breaks later and somewhere else: the day the application drops that dependency, or the day the package is consumed from anywhere that does not have it hoisted. By then the commit that caused it is long merged, and nothing about the failure points back at it.

This class of bug is invisible to the compiler by construction. Lint is the only place it can be caught, which is why these are errors rather than warnings — the thing they catch has already passed every other gate.

Shared code costs a repository. @arda-cards/design-system and @arda-cards/api-proxy each live in their own repository with their own pipeline. That is the right weight for a design system and the wrong weight for a lint rule or a release script, and the overhead means code that should be shared simply is not.

One version cannot describe several artifacts. The application has one version and one changelog. A package published from the same repository needs its own, computed from its own history, or a consumer upgrading it cannot find out what moved. Decision 4 is the versioning structure this proposes. The release pipeline analysis already lists the symptoms today’s arrangement produces — “check the version doesn’t clash”, and resolving changelog merge conflicts by hand-editing version numbers, twice per change.

Everything below is downstream of these.

  • npm workspaces. No new build system and no second lockfile.
  • Blocking inside packages/, advisory in the application. The package graph starts empty and stays small, so it can be held to a standard the application has not reached.
  • The bundler compiles packages locally; tsc produces what publishes. A change inside a package must live-update in the dev server with no per-package build and no watcher — and raw TypeScript must never reach a consumer, which would compile it with that consumer’s settings rather than ours.
  • TypeScript only inside a package.ts and .tsx both. What is excluded is everything that needs a loader to resolve: no stylesheets, no asset imports, no webpack ! syntax, no ?raw. Only Next has those loaders, so they would work in the dev server and break once published.
  • The existing pull-request-body changelog flow extends rather than being replaced.

The directory is packages/, and it holds libraries, not deployables — things consumed by an import or a command, versioned and published on their own. The application is the deployable, and it stays at the root.

packages/ is npm’s own word for exactly this, so tooling, contributors and every answer on the internet already agree on what is inside it; the descriptive part of the name belongs to the package (@arda-cards/eslint-config says what it is far better than its parent directory could). A more specific name like shared/ or core/ was considered and rejected on the same grounds: it describes the relationship to the application rather than the kind of artifact, and the relationship is what varies between packages.

If a second kind of artifact does turn up later — something deployed rather than published, say — it earns a sibling directory named after what it is, added as one more workspace glob. That is a cheap change, and speculating about it now would only produce a vaguer name today.

A rule that needs to know something about a package reads the field the ecosystem already uses to say it.

FieldWhat it decides
peerDependenciesThe right to import the framework — and that the consumer supplies the copy rather than the package bundling its own
engines.nodeThat Node loads the package directly, so its entry point must be compiled
exportsWhat is public surface, and where the entry point is
sideEffectsWhether a bundler may drop the file when nothing imports it
publishConfigWhere the package publishes to

Two of these are read more narrowly than they are sometimes described, and the difference matters:

  • peerDependencies asks; it does not guarantee. It stops the package declaring a private copy of the renderer and makes the consumer supply one — but npm can still resolve two copies when ranges disagree. Single-instance is what peers-satisfied-by-root and dependency-versions-agree enforce, by requiring one range per library across every manifest and a peer range the root actually satisfies.
  • engines.node is a repository convention, not an ecosystem runtime marker. In npm it states which Node versions a consumer must have; nothing makes it a statement about who loads the package. Here it is used as one, because a package Node loads directly genuinely has a Node floor and should say so. entry-points-match-runtime is what keeps the convention honest — it will not let a package declare engines.node and then name a source entry point.

Rejected: a policy surface of our own. The first design had a tier list inside the lint config naming which packages were allowed the framework. A hand-maintained list is a second source of truth; it drifts silently, and the drift is invisible exactly where it matters — the allowlist still says “allowed” long after the dependency is gone. Reading the manifest instead means the existing import/no-extraneous-dependencies rule needs no allowlist at all, and a new package needs no edit to any config.

What it costs. A package that omits a field opts out of the check that reads it. That is the right default for sideEffects — an omitted claim is an honest “no promise” — but where a field is required rather than optional, a rule says so: see packages-declare-exports and publishable-packages-are-complete in Decision 3.

Browser tier (no engines.node)Node tier (engines.node)
exports./src/index.ts./dist/index.js
publishConfig.exports./dist/index.js
Compiled byThe app’s bundler, with the apptsc, before anything loads it
Node built-insRefusedAllowed

The browser tier exports source, so the bundler compiles it alongside the application and a change live-updates with no build step and no stale dist/ to read. publishConfig.exports names dist/, and the publish step swaps it in, so a consumer receives tsc output. The Node tier has no split to make: a linter or a workflow loads it straight through Node, which cannot read TypeScript.

Who performs the swap matters, because npm does not. publishConfig overriding manifest fields is a pnpm and Yarn feature; npm applies only config values from it, such as registry. Verified on npm 10.9.2 — npm pack on a package declaring publishConfig.exports produces a tarball still naming the source path. So the swap is ours to perform, and it needs no new machinery, because the publish path already rewrites this manifest:

npm run build:packages tsc produces dist/
changelog resolve-ranges rewrites the manifest on disk ← the swap belongs here
npm version injects the version from the changelog
npm publish
git checkout -- packages/ throws the edits away

The step that turns "*" into ^<sibling's version> is already the answer to “what manifest should this tarball carry?”, already runs between the build and the publish, and is already a tested command rather than shell (Decision 5). Applying publishConfig.exports is one more field in that transform. The repository’s own manifest keeps naming source, as it already keeps "*".

Two things make this safe rather than clever. files lists dist, so src/ never enters the tarball whatever exports says — the swap decides what resolves, not what ships. And the whole edit is discarded by the git checkout that already ends the run, so a failed publish cannot leave a rewritten manifest behind.

Rejected: a per-package prepack script. It works — npm re-reads package.json after prepack, so a script that rewrites exports there does reach the tarball. It is also the same publish decision, taken in a place nothing tests, once per package, which is the duplication Decision 5 exists to prevent.

Rejected: a per-package build with a watcher. It works, and it means every local change waits on a build, every contributor has one more process to remember, and a stale dist/ is one forgotten rebuild away. Exporting source removes the failure mode rather than managing it.

Rejected: shipping TypeScript to consumers. A consumer’s build would compile it with whatever settings that consumer happens to have.

Ten rules, in a plugin and a shared config published from the same workspace. Eight read package.json — the failures they catch appear in no source file at all — and two read source.

RuleReadsCatches
shared-singletons-are-peersManifestsA framework or store singleton in dependencies instead of peerDependencies
dependency-versions-agreeManifestsOne library carrying two version ranges across the workspace
peers-satisfied-by-rootManifestsA peer range the root cannot satisfy
no-manifest-cyclesManifestsA cycle declared between packages
internal-deps-resolveManifestsA scoped name that resolves to nothing
packages-declare-exportsManifestsA package with no exports at all
entry-points-match-runtimeManifestsEntry points, files and build that do not match who loads the package
publishable-packages-are-completeManifestsA publishable package missing what publishing needs
filenames-match-exportsSourceA file or directory named against the convention
side-effects-match-manifestSourceA module that does something at import time in a package that claims it does not

The three that answer “what must a package declare?” are packages-declare-exports, entry-points-match-runtime and publishable-packages-are-complete. Between them, a new package is told what is missing, field by field, by the linter rather than by review.

Rejected: a bespoke CI script. It would run in one place, at one time, with its own output format. A lint rule runs in the editor as it is typed, names a line, and fails the way everything else fails.

Cycles are split by cost. The module-level rule runs inside a package, where the graph is small. Cycles between packages are checked against the manifest graph instead — exact, and far cheaper than following a symlink into a sibling’s source. This is the npm-package analogue of the backend’s DAG package discipline: the same no-cycles rule, checked at the level the ecosystem gives us.

A package’s committed version is a placeholder. The version lives at the top of that package’s CHANGELOG.md, and the publish step injects it. An entry in a pull request body prefixed (name) routes to that package, an unprefixed entry to the application, and each scope is versioned from its own entries against its own history — so an application-level Changed cannot major-bump a package that only received a Fixed.

There is no repository-wide version. Each package has its own number from its own changelog; the application has its own; assembly touches the root package.json and nothing else. Packages publish to GitHub Packages (npm.pkg.github.com), which is where @arda-cards/design-system and @arda-cards/api-proxy already publish, so consumers need no new registry configuration.

No one edits a CHANGELOG.md, so more of them cost nothing. This is the same flow the repository already runs, and the reason it exists: entries are written in the pull request body, CI rejects a pull request that hand-edits a changelog file, and the post-merge assembly workflow writes every affected file on main under a concurrency group that serializes it. A branch never touches those files, so nothing about the merge queue changes when there are four of them instead of one — the conflicts the flow was adopted to remove stay removed.

Three rules that are not plain semver:

  • A 0.x package stays in 0.x. While the major is 0, a breaking category bumps the minor. ^0.1.0 already does not match 0.2.0, so the minor carries the break, and reaching 1.0.0 stays a deliberate act rather than the side effect of a rename.
  • Releasing a package releases its dependents, as a patch with a generated note. This is a statement about tarballs, not about adoption. In the repository, siblings depend on each other with "*"; publish rewrites that to ^<sibling's version>. So when a sibling’s version moves, the dependent’s published manifest differs from the one already on the registry and needs a number of its own. Without it the range rewrite is inert and no consumer of the dependent ever receives the sibling’s release. Nothing is being claimed about the dependent’s own surface, which is why the increment is a patch and never a major. A dependent is never released before it is ready, because readiness is settled before the merge rather than at release time: the "*" range means a breaking change in a sibling is compiled and tested against every in-repository dependent in the same pull request. If a dependent cannot take the change, that pull request fails — there is no window in which a released dependent has not yet adopted its sibling. External consumers upgrade on their own schedule, as with any package.
  • Unless the dependent has an entry of its own, in which case it takes that increment instead. That is the case where the dependent’s own surface did change, and an editorial note beats a generated one.

Rejected: the version in package.json. Then a merge has to write it, every branch conflicts on it, and the number in the file can disagree with the number on the registry. Keeping it in the changelog also matches how @arda-cards/design-system and @arda-cards/api-proxy already publish.

Rejected: changesets. A capable tool, and a second changelog format alongside the pull-request-body flow this organization already runs, plus a per-change file to remember. The existing flow needed a scope prefix, not a replacement.

5. Release automation does I/O; a tested package makes the decisions

Section titled “5. Release automation does I/O; a tested package makes the decisions”

A shell script inside a workflow step cannot be unit-tested. So every judgment about versions, categories, scopes and ranges is a command in a workspace package with unit tests, and the workflows are left calling gh, git and npm.

This boundary was drawn after two duplicates crossed it and both drifted:

  • The pull-request gate validated changelog categories in shell against a hardcoded list, and required only that one known heading be present. A heading like ### Fixes therefore passed the gate, its entries were written into the changelog, and the release it should have triggered never happened. Categories are now judged against the same category map the version increment is read from, so the two cannot disagree.
  • The publish workflow rewrote internal dependency ranges in shell, duplicating a function that had tests.

The general form: a duplicate of tested logic, written where nothing tests it, will drift — and the drift surfaces as the guardrail passing something it should have caught.

🧪 What this looks like with real packages

Section titled “🧪 What this looks like with real packages”

The three packages the implementation introduces, which are also the three that make the rest of it work:

PackageWhat it isWho loads itDepends on
@arda-cards/eslint-configThe shared flat config — a fresh baseline, nothing carried overESLint@arda-cards/eslint-plugin
@arda-cards/eslint-pluginThe ten rules aboveESLint
@arda-cards/changelogPlans the releases a pull request implies; ships the arda-changelog commandThe release workflows

All three are Node tier — they declare engines.node, export ./dist/index.js, ship files: ["dist"], and publish to GitHub Packages. Nothing here exercises the browser tier yet, which is why its publish-time swap is called out as unproven below. eslint-config declares eslint as a peer, because ESLint is the process loading it and there must be one. changelog is a library and a program, so rather than claiming sideEffects: false and exempting the entry point, it names it:

"sideEffects": ["./src/cli/index.ts", "./dist/cli/index.js"]

Both paths, because the source is what a bundler sees inside the repository and dist/ is what a consumer sees.

A pull request touching the plugin and the application writes one section, and it becomes two releases:

## CHANGELOG
### Added
- (eslint-plugin) A rule requiring every package to declare `exports`
- Notification preferences on the account page

The prefixed entry is versioned against packages/eslint-plugin/CHANGELOG.md; the unprefixed one against the application’s. eslint-config depends on the plugin, so it picks up a generated patch and a note naming the new range. Three tags, three releases, two tarballs — and nobody edited a version number or a changelog file.

The implementation pull request carries these packages, their fixture workspaces, and packages/README.md describing the same flow from a contributor’s side.

DecisionChosenRejectedBecause
Workspace toolingnpm workspacespnpm, Turborepo, NxAlready installed; no second lockfile or build graph to learn
Directory namepackages/shared/, core/, common/npm’s own word for published libraries; the specific name belongs to the package
Import permissionpeerDependencies and friendsA tier list in lint configThe manifest is what npm reads too, so it cannot drift
Local buildsThe bundler compiles sourcePer-package build + watcherRemoves the stale-dist failure mode instead of managing it
Published outputThe publish step applies publishConfig.exportsTrusting npm to apply it; a per-package prepacknpm applies no field overrides, and the swap belongs where it is tested
Shipping raw TypeScriptNever; dist via filesShip sourceConsumers would compile with their settings, not ours
RegistryGitHub PackagesPublic npmWhere the existing @arda-cards packages already publish
SeverityErrorsWarningsThe bug has already passed build, types and tests
Cycles in the applicationAdvisoryBlockingA pre-existing backlog is a refactor, not a guardrail
Version sourceThe changelogpackage.jsonNo merge conflicts, no disagreement with the registry
Release toolingExtend the existing flowchangesetsOne changelog format, not two
0.x breaking changeMinorMajor, to 1.0.0^0.x does not cross a minor; 1.0.0 should be deliberate
Dependent releasesAutomatic patchNothingOtherwise the range rewrite is inert
Side-effect claimDeclared and checkedDeclared onlyA one-word promise nothing verifies is not a guarantee
sideEffects where a package ships a CLIArray naming the programfalse plus a lint exemptionKeeps the manifest true rather than making the linter look away
Formatting and naming rulespackages/ onlyRepository-wideReformatting an existing application buries the next diff
Tests resolving workspace packagesA Jest mapper, to sourceBuilding before testsA unit test should not depend on whether someone built first

Adding a dependency. It belongs to the package that imports it, declared in that package’s own manifest. Being installed at the root is not permission. Anything that must exist exactly once in the process — the renderer, the framework, a store, a CSS-in-JS library — goes in that package’s peerDependencies, and declaring it is what grants the import. The declaration is made by the package doing the importing, which is the one being published; the consumer is the one that has to satisfy it, and peers-satisfied-by-root checks that the root does.

Adding a package. No config edit anywhere: the workspace is read when the lint config loads. A directory, a manifest, a three-line tsconfig.json, and a changelog starting at 0.0.0. The manifest rules will say, precisely, what is missing.

Writing a changelog entry. In the pull request body, prefixed (name) for a package. A pull request that changes a package’s files must carry an entry for it — the gate refuses otherwise, because after the merge the release has already happened.

The browser tier’s publish swap is unproven. The mechanism is settled — the publish step applies publishConfig.exports, in the transform that already rewrites internal ranges — but no browser-tier package exists, so no tarball has ever been produced through it. The first one needs its swap proven by unpacking a real tarball, not by reading the workflow.

npm publish --dry-run cannot prove it, and will suggest the opposite. --dry-run skips lifecycle scripts and reports the manifest as committed, so a source-exporting package inspected that way shows its source entry point either way — whether the swap works, or is missing entirely. npm pack runs the real path; unpack the .tgz and read package/package.json. This is the prove-a-rule-fires principle applied to publishing, and it is the specific trap that let the original “npm swaps it in” claim stand: the check that looks like verification reports the same thing in both directions.

engines.node carries a meaning npm does not give it. It works as the tier discriminator because we treat it as one and lint keeps it consistent, not because the ecosystem agrees: in npm it states which Node versions a consumer must have. A dedicated marker would be more honest; it would also be a field only we read, which is the second source of truth Decision 1 exists to avoid. The case to revisit it arrives the day a browser-tier package needs a Node floor of its own, because then the two meanings genuinely collide.

The publish path cannot be exercised before a merge. It runs after changelog assembly, so the first merge is its first execution. A package already on the registry is skipped rather than failing the run, and the workflow can be dispatched manually afterwards — but if it fails, the tags and releases will exist while the tarballs do not.

Changelog validation covers the application’s changelog only. The per-package changelogs are not yet checked by the format validator, though their headers claim they are. Either wire it per package or drop the claim.

Two conventions coexist. Formatting and naming rules apply inside packages/ and deliberately not to the application. Correct for now; worth a decision rather than a drift.

Identifier casing is deliberately loose for variables. All three formats are accepted, because no rule can distinguish a binding that happens to be const from a constant — const cache = new Map() is an instance, not a constant.

This proposal covers structure, not contents. Which existing code should become a package, and in what order, is a separate question.

Each manifest rule is driven through the linter’s own rule-tester, and the composed config is run against fixture workspaces — miniature repositories that deliberately include a package which breaks the rules it exists to exercise. A rule that never reaches the plugin’s registry fails the suite, because a rule registered nowhere runs nowhere and nothing else would notice.

Unit tests reach workspace packages through a Jest moduleNameMapper that resolves each @arda-cards/<name> to that package’s src/index.ts, rather than through its exports. The mapper is built by reading the workspace directory, so adding a package needs no edit. This is deliberate: exports names dist/, and a test that resolved through it would pass for anyone who had built recently and fail in CI, which does not build before testing.

Two principles the checking itself has to follow, both learned from a gate that passed something it should have caught:

  1. Verify in the environment CI uses, not the one you happen to have. The exports-resolution failure above is the example: green locally, red in CI, and the difference was a stale build nobody had thought of as state.
  2. Prove a rule fires, rather than trusting a green run. An opt-in rule that silently never runs looks identical to a clean repository. Feed it a violation and confirm it reports — then confirm it stays quiet where it should.
  3. Check the artifact, not the command’s account of it. The publish-time swap above is the case in point: npm publish --dry-run skips lifecycle scripts and reports the committed manifest, so it answers a different question than the one being asked. npm pack and an unpacked tarball answer the real one.

What no amount of this reaches is git push, git tag and npm publish. There, a run is the only test.

  • Release Pipeline Analysis — the end-to-end cost of shipping a shared-component change today, including the per-change changelog and version friction this removes.
  • DAG Package Discipline — the same no-cycles rule on the backend, checked at Kotlin package level.
  • Naming Conventions — the organization-wide naming rules the file and identifier rules here enforce mechanically inside packages/.
  • State and Context Conventions — the companion frontend proposal, covering what the application owns rather than how shared code is packaged.