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.
📍 Why this is necessary
Section titled “📍 Why this is necessary”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.
🎯 Constraints
Section titled “🎯 Constraints”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;
tscproduces 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 —
.tsand.tsxboth. 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.
🗂️ Where they live
Section titled “🗂️ Where they live”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.
🧱 Five decisions
Section titled “🧱 Five decisions”1. The manifest grants the permission
Section titled “1. The manifest grants the permission”A rule that needs to know something about a package reads the field the ecosystem already uses to say it.
| Field | What it decides |
|---|---|
peerDependencies | The right to import the framework — and that the consumer supplies the copy rather than the package bundling its own |
engines.node | That Node loads the package directly, so its entry point must be compiled |
exports | What is public surface, and where the entry point is |
sideEffects | Whether a bundler may drop the file when nothing imports it |
publishConfig | Where the package publishes to |
Two of these are read more narrowly than they are sometimes described, and the difference matters:
peerDependenciesasks; 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 whatpeers-satisfied-by-rootanddependency-versions-agreeenforce, by requiring one range per library across every manifest and a peer range the root actually satisfies.engines.nodeis 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-runtimeis what keeps the convention honest — it will not let a package declareengines.nodeand 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.
2. Two tiers, one field apart
Section titled “2. Two tiers, one field apart”Browser tier (no engines.node) | Node tier (engines.node) | |
|---|---|---|
exports | ./src/index.ts | ./dist/index.js |
publishConfig.exports | ./dist/index.js | — |
| Compiled by | The app’s bundler, with the app | tsc, before anything loads it |
| Node built-ins | Refused | Allowed |
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 herenpm version injects the version from the changelognpm publishgit checkout -- packages/ throws the edits awayThe 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.
3. Lint is the enforcement point
Section titled “3. Lint is the enforcement point”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.
| Rule | Reads | Catches |
|---|---|---|
shared-singletons-are-peers | Manifests | A framework or store singleton in dependencies instead of peerDependencies |
dependency-versions-agree | Manifests | One library carrying two version ranges across the workspace |
peers-satisfied-by-root | Manifests | A peer range the root cannot satisfy |
no-manifest-cycles | Manifests | A cycle declared between packages |
internal-deps-resolve | Manifests | A scoped name that resolves to nothing |
packages-declare-exports | Manifests | A package with no exports at all |
entry-points-match-runtime | Manifests | Entry points, files and build that do not match who loads the package |
publishable-packages-are-complete | Manifests | A publishable package missing what publishing needs |
filenames-match-exports | Source | A file or directory named against the convention |
side-effects-match-manifest | Source | A 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.
4. The changelog is the version
Section titled “4. The changelog is the version”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.0already does not match0.2.0, so the minor carries the break, and reaching1.0.0stays 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
### Fixestherefore 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:
| Package | What it is | Who loads it | Depends on |
|---|---|---|---|
@arda-cards/eslint-config | The shared flat config — a fresh baseline, nothing carried over | ESLint | @arda-cards/eslint-plugin |
@arda-cards/eslint-plugin | The ten rules above | ESLint | — |
@arda-cards/changelog | Plans the releases a pull request implies; ships the arda-changelog command | The 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 pageThe 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.
📋 Decisions in brief
Section titled “📋 Decisions in brief”| Decision | Chosen | Rejected | Because |
|---|---|---|---|
| Workspace tooling | npm workspaces | pnpm, Turborepo, Nx | Already installed; no second lockfile or build graph to learn |
| Directory name | packages/ | shared/, core/, common/ | npm’s own word for published libraries; the specific name belongs to the package |
| Import permission | peerDependencies and friends | A tier list in lint config | The manifest is what npm reads too, so it cannot drift |
| Local builds | The bundler compiles source | Per-package build + watcher | Removes the stale-dist failure mode instead of managing it |
| Published output | The publish step applies publishConfig.exports | Trusting npm to apply it; a per-package prepack | npm applies no field overrides, and the swap belongs where it is tested |
| Shipping raw TypeScript | Never; dist via files | Ship source | Consumers would compile with their settings, not ours |
| Registry | GitHub Packages | Public npm | Where the existing @arda-cards packages already publish |
| Severity | Errors | Warnings | The bug has already passed build, types and tests |
| Cycles in the application | Advisory | Blocking | A pre-existing backlog is a refactor, not a guardrail |
| Version source | The changelog | package.json | No merge conflicts, no disagreement with the registry |
| Release tooling | Extend the existing flow | changesets | One changelog format, not two |
| 0.x breaking change | Minor | Major, to 1.0.0 | ^0.x does not cross a minor; 1.0.0 should be deliberate |
| Dependent releases | Automatic patch | Nothing | Otherwise the range rewrite is inert |
| Side-effect claim | Declared and checked | Declared only | A one-word promise nothing verifies is not a guarantee |
sideEffects where a package ships a CLI | Array naming the program | false plus a lint exemption | Keeps the manifest true rather than making the linter look away |
| Formatting and naming rules | packages/ only | Repository-wide | Reformatting an existing application buries the next diff |
| Tests resolving workspace packages | A Jest mapper, to source | Building before tests | A unit test should not depend on whether someone built first |
🧭 What it means day to day
Section titled “🧭 What it means day to day”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.
⚠️ Risks and open questions
Section titled “⚠️ Risks and open questions”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.
✅ How a guardrail is verified
Section titled “✅ How a guardrail is verified”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:
- 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. - 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.
- Check the artifact, not the command’s account of it. The publish-time swap above
is the case in point:
npm publish --dry-runskips lifecycle scripts and reports the committed manifest, so it answers a different question than the one being asked.npm packand 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.
🔗 See also
Section titled “🔗 See also”- 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.
Copyright: © Arda Systems 2025-2026, All rights reserved