Skip to main content
Version: 1.0

Packages

A packages map holds per-package configuration, keyed by package name (matched case-insensitively, like every config map key). The file's own top-level map is the broadest place to write one; a space can hold the same entries for its own packages, and so can a space configuration file. They all take the same entry shape, and the override ladder says which one wins.

A top-level entry plays one of two roles:

  • An override for a space package. An entry without a path adjusts the configuration of the package whose folder name matches the entry key. Every such key must match exactly one package folder across all spaces. An unmatched key is the same class of typo as an unknown dependency endpoint, and a key matching a .dispatexcluded folder is rejected with the exclusion spelled out. One-off exceptions do not require carving the package out into a space of its own.
  • A standalone package. An entry with a path declares a package living outside every space, at that root-relative folder; the entry key is the package name and the entry itself is the package's whole configuration.
{
"spaces": {
"libs": {
"path": "packages",
"flow": {
"build": "build",
"publish": "publish"
}
}
},
"packages": {
"core": {
"revertOnFail": false,
"changelog": {
"file": "HISTORY.md"
}
},
"cli": {
"path": "tools/cli",
"flow": {
"build": "build-go"
},
"dependencies": [
"core"
]
}
}
}

Package options

An entry mirrors the space options minus the space-defining keys, and adds the package-only keys:

KeyTypeEffect
pathstringDeclares a standalone package at this root-relative folder, always exactly one folder, unlike a space's path. Only valid on an entry whose key matches no space folder (a space package's location is its folder, so redefining it is rejected), and never valid in an in-folder file.
changelogobjectOverlays the top-level changelog field by field for this package's release records: flip enabled, rename the file, retitle a section; unset fields keep the global values. A line list set here replaces the inherited one rather than adding to it.
githubobjectOverlays the top-level github the same way: a package can disable its GitHub releases or target another repository while keeping the global tokenEnv. Distinct effective targets each get their own up-front verification.
concurrencyint or [b, p]The package's weight: how many slots of the stage concurrency budgets its tasks occupy. See package weights below.
versioningstringHow much of the version this one package holds in common with its group; see versioning. Most usefully independent, opting one package out of a shared space, but any mode is settable and the package stays in its space's group under it.
versionGroupstringJoins this one package to a versioning group.
dependenciesstring or arrayProvider names this package depends on; the consumer is the package itself.
manifestNamesarray of stringsThe manifest names this package answers to, stated rather than read from its files. See manifestNames below.
srcstringA folder-relative path narrowing which of the package's files count as changes to it. Also settable on a space or at the root. See src below.
ignorearray of stringsPatterns keeping some of the package's own files from counting as changes to it. Also settable on a space or at the root, where the levels add up. See What counts as a change.
envmap name → valueFixed environment variables for this package's scripts, merged key by key over the space's map and the top-level one; see Static env.
customobjectFree-form data dispat never reads; see custom. Nothing merges it: an entry's object and an in-folder file's object are independent.

For an entry overriding a space package, a field left unset inherits from the space; a field set overrides it. The per-field rules follow from what each object means:

  • The boolean options (isBuildWaitingPublish, revertOnFail) are tri-state in an override: absent inherits, an explicit false overrides a space's true.
  • flow merges entry by entry: an overridden stage or hook replaces that entry's list, every other entry inherits, and an explicit empty array ("build": []) clears an inherited entry. The names inside are looked up against this package first, then its space, then the file, so a package can keep its space's flow.build: build and still supply its own build command. A name missing from all three levels is an error naming the package. flow.login cannot be set per package: login runs once per space, in the space folder, gating every publish of the space, and a per-package login would contradict all three.
  • scripts merges name by name: a name set here wins, the space's other names survive, and the file's names stay under both. What a name binds is replaced whole, however many commands that is, so restating a multi-command script here is a new sequence rather than an addition to the inherited one. A name only this package defines belongs to this package alone, so dispat run <name> reaches no other package with it. See scripts and dispat run.
  • versioning/versionGroup are one axis: a layer setting either supersedes both inherited values (so a package sets versioning: independent to opt out of its space's group, or versionGroup: <name> to join another). Setting both in one layer is a contradiction and is rejected. Setting versioning to another shared mode does not leave the space's group: the package keeps its membership and asks to share a different amount, which the group resolves to the deepest any member asked for (W237). Overriding to a sparse mode changes only when the package releases, not whether its version counts: the group's next version is computed from every member's published version either way, so a sparse member's tag can still decide where the rest of the group lands. See joining with a versioning of its own.
  • autoVersion replaces wholesale: its empty fields already carry meaning relative to their siblings (no kinds means all four), so a field-level overlay could never express them against a non-empty base. An override of {"enabled": false} switches the space's block off for the package.
  • manifestNames replaces wholesale, like every other list: the layer nearest the package states what the package is called, and adding to an inherited list could never take a name away again.
  • tagFormat overrides like everywhere else: package over space over repository.

Two keys are refused on an entry wherever it is written: packages and spaces. An entry configures one package, so it holds neither packages nor spaces of its own. path is refused everywhere except the file's top-level map, where it declares a standalone package.

The override ladder

One package's configuration can be spoken to from six places. They apply in this order, each overlaying the one before it field by field, and the layer nearest the package wins:

#LayerWhere it lives
0root defaultsroot file, the top-level space-shaped keys
1space configroot file, spaces.<space>
2space configuration file<space folder>/dispat.json, its top-level object
3root package entryroot file, packages.<package>
4space package entryroot file, spaces.<space>.packages.<package>
5space file package entry<space folder>/dispat.json, packages.<package>
6package configuration file<space folder>/<package>/dispat.json

Layer 0 is the repository's own defaults for the keys a space could state (flow, autoVersion, versioning, tagFormat, aliasTags, src, ignore, isBuildWaitingPublish, revertOnFail), so a setting every space shares is written once; see Where a setting can live. Layers 1 and 2 are the space, and describe every package in it. Layers 3 to 6 each name one package, ordered by how close to it they are written: the repository as a whole, then the space, then the space's own folder, then the package's own folder.

"Nearest wins" is per field, not per layer. A farther layer still supplies everything the nearer ones leave unset, so setting changelog.file at the top level and revertOnFail in the package's own file gives the package both.

A standalone package has no space, so only layers 3 and 6 apply, over the root defaults: it is its own space.

root dispat.json
{
"spaces": {
"libs": {
"path": "packages",
"tagFormat": "libs/{name}@{version}",
"packages": { "core": { "revertOnFail": true } }
}
},
"packages": { "core": { "changelog": { "file": "HISTORY.md" } } }
}
packages/core/dispat.json
{ "tagFormat": "core/{name}@{version}" }

core releases under core/core@1.2.3 (layer 6 beat layer 1), with revertOnFail on (layer 4) and its changelog in HISTORY.md (layer 3, which nothing nearer contradicted).

manifestNames

dispat works out which package a dependency refers to by reading the name each package's manifests declare. A package.json says "name": "@acme/core", so a sibling depending on @acme/core is depending on that folder. That covers most repositories without any configuration at all.

Some packages declare no name anything here can read. A Gradle module keeps its coordinate in a build script that is a program rather than a manifest. A folder built by a Makefile declares nothing. A project in an ecosystem dispat has no parser for is opaque by definition. Nothing points at these packages, so dispat compute derives no edges into them and auto-versioning never reconciles the declarations that name them.

manifestNames is how you say what such a package is called:

packages:
core:
manifestNames: [ "com.acme:core" ]

From then on a dependency spelled com.acme:core anywhere in the workspace resolves to the core package, for dispat compute and for auto-versioning alike. The two share one index, so they cannot disagree about what a name means.

Two rules keep it honest. A stated name outranks one a manifest declares, because it is you saying so rather than a file happening to say it. And no two packages may state the same name: a manifest name identifies one package, and a collision here is a typo in your configuration rather than a fact about the repository, so it fails to load.

The key belongs to a package, not to a space, so it lives in a packages entry or in the package's own in-folder file.

src

A commit that names no scope is attributed by the files it touched: whichever package owns a changed path is the package the commit addresses. Ownership is the package folder, so everything in the folder counts, which is usually right and occasionally not. A package whose folder also holds a docs site, a fixtures tree or a scratch directory releases on a typo fix in prose.

src narrows that to one sub-folder:

packages:
core:
src: lib

A changed file now has to sit under packages/core/lib to make a scopeless commit address core. Anything else in the folder belongs to whichever package encloses it, or to no package at all.

What src does not change is worth stating, because it is most of the package:

  • The package folder is still the package. Scripts run there, the changelog is written there, and the release commit stages all of it, src or not.
  • Manifests are still found in the whole folder. A package.json or go.mod usually sits at the package root, outside src, and auto-versioning and dispat compute must still reach it.
  • A scope always wins. fix(core): ... addresses core wherever the commit's files are. src narrows the file-derived fallback, which is what runs when a commit names no scope at all. See scope sets.

A src that could never match is refused at load: a folder that is not there, a path leaving the package, or the package folder itself. Each of those would narrow the package to nothing, and a package that quietly stops releasing is the failure this check exists to prevent.

src can also be written on a space or at the root, where it becomes the default for every package it reaches, still resolved against each package's own folder. To exclude some files rather than pick one folder, see ignore; the two work together.

Package weights: concurrency

A package entry's concurrency is a weight, scalar or [build, publish] pair: how many slots of the stage budgets the package's tasks occupy. Absent and 0 mean 1, the ordinary cost. This deliberately differs from the top-level key, where 0 means the CPU count; a weight has no CPU reading.

A package whose weight reaches a stage's budget runs that stage alone. That is the slot for the Android build that would starve every neighbour of memory. Weights change slot accounting, never ordering, and a waiting heavy package is never overtaken by lighter ones that became ready after it.

Standalone packages: path

An entry with a path is a package outside every space: a tools folder next to the workspaces, a deploy bundle at the repository top, or anything else that releases like a package but shares no parent folder with one. The path is relative to the monorepo root, must stay inside the repository (no absolute paths, no ..), and must name an existing folder.

A standalone package is a full package in every respect: it plans, versions, builds, publishes, tags and writes records exactly like a space package. Its effective configuration is built through the same layers as an override, starting from an empty base instead of a space: the entry, then the package's own in-folder file, field by field. Three consequences of having no space:

  • The package is its own single-package space, named after the entry key: its implicit versioning group is its own name, and versionGroup joins it to any other group.
  • There is no flow.login, because login is a space-level stage. A standalone package that needs authentication puts it in flow.beforePublish.
  • .dispatexclude does not apply; the entry alone decides that the folder is a package.

Config map keys are lowercased by the loader, so a standalone package's name (the entry key) is effectively lowercase, like space names.

Package dependencies

A package may declare the providers it depends on directly in its entry (or in its in-folder file), which keeps its dependencies next to the rest of its configuration:

{
"packages": {
"web": {
"dependencies": [
"core",
{ "provider": "utils", "keep": true },
{ "provider": "tooling", "kind": "devDependencies" }
]
}
}
}

The entries are the ones a consumer lists in the top-level dependencies object, so an edge reads the same wherever it is declared and moving one between the two places is a cut and a paste. The consumer is the package itself. One provider needs no array: "dependencies": "core".

All declarations (the top-level object, every entry's list, every in-folder list) merge into one list; where an edge is declared changes nothing about how it plans.

dispat compute treats every declaration source as one merged list, then edits each declaration in the entry that holds it, whichever layer that is. A stale edge declared in a space's packages entry is removed from the root config. One declared in a space file is removed from that file, and one declared in a package's own file from there. Every suggestion names its source, so spaces["libs"]: packages["core"]: dependencies[0] says exactly what an applied change would touch. A kind correction is applied in place, since a package's list carries a kind as readily as the top-level object does.

A detected addition goes where its consumer already declares its providers, and to the top-level object when it declares none. A config that keeps each package's dependencies in that package's entry stays that way instead of growing a second home for the edges the next compute finds. Each edited file gets its own .backup.

In-folder configuration files

A package folder may carry a dispat config file of its own, under the same names and formats the root config resolves through (dispat.json, dispat.yaml, dispat.yml, dispat.toml, first match wins, and a .dispatexclude in the folder chooses between them). Its top-level object is exactly the package entry object above minus path (a file cannot move the folder it lives in), and it is the most local layer, the last rung of the ladder. The same merge rules apply, unknown keys are rejected with the file named, and the file travels with the package: a package moved between spaces keeps its exceptions.

// packages/core/dispat.json
{
"revertOnFail": true,
"versionGroup": "platform"
}

A package folder's file that declares spaces or packages is refused with guidance: the folder holds a monorepo root of its own (a vendored or nested repository) and must be excluded via .dispatexclude, not half-merged. A space folder's file is the one place packages belongs outside the root config; see the space configuration file.

Config resolution is aware of every one of these files: running the CLI from inside a package folder ascends past the package's own file, and past its space's, to the monorepo root, so cd packages/core && dispat lint works whatever the folders on the way carry.