Skip to main content
Version: 1.1

Packages

A packages map holds per-package configuration. You key these entries by package name, and dispat matches them case-insensitively like every config map key. You can write this map at the top level of your root file, inside a space for its own packages, or in a space configuration file. All three places take the same entry shape, and the override ladder decides 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. This key must match exactly one package folder across all spaces. dispat rejects an unmatched key as a typo, and rejects a key matching a .dispatexcluded folder with the exclusion spelled out. You can handle one-off exceptions this way without 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 becomes the package name. The entry itself holds 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

A package entry mirrors the space options minus the space-defining keys. It also adds these package-only keys:

KeyTypeEffect
pathstringDeclares a standalone package at this root-relative folder. This is always exactly one folder, unlike a space's path. You can only set this on an entry whose key matches no space folder, because a space package's location is its folder, and never in an in-folder file.
changelogobjectOverlays the top-level changelog field by field for this package's release records. You can flip enabled, rename the file, or retitle a section. Unset fields keep the global values, but 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. You will mostly use independent to opt one package out of a shared space, but you can set any mode and the package stays in its space's group.
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 here 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. You can also set this 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. You can also set this on a space or at the root, and the levels add up. See What counts as a change.
envmap name → valueFixed environment variables for this package's scripts. dispat merges these 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 this data, so 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 you set overrides it. The per-field rules follow from what each object means:

  • The boolean options (isBuildWaitingPublish, revertOnFail) are tri-state in an override. An absent field 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. dispat looks up the names inside 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. You cannot set flow.login per package because login runs once per space, in the space folder and gates every publish of the space. A per-package login would contradict all three facts.
  • scripts merges name by name. A name set here wins, the space's other names survive, and the file's names stay under both. dispat replaces the bound commands whole, so restating a multi-command script here creates a new sequence rather than adding to the inherited one. A name only this package defines belongs to this package alone, meaning 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. dispat rejects setting both in one layer as a contradiction. Setting versioning to another shared mode does not leave the space's group, but asks to share a different amount that 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. Write an override of {"enabled": false} to switch 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. Adding to an inherited list could never take a name away again.
  • tagFormat overrides like everywhere else: package over space over repository.

dispat refuses two keys on an entry wherever you write it: packages and spaces. An entry configures one package, so it holds neither packages nor spaces of its own. dispat refuses path everywhere except the file's top-level map, where it declares a standalone package.

The override ladder

You can configure one package from six places. They apply in this order, each overlaying the one before it field by field. 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). You write a setting every space shares once here. See Where a setting can live.

Layers 1 and 2 are the space, and they describe every package in it. Layers 3 to 6 each name one package, ordered by how close to it you write them. The order is the repository as a whole, then the space, then the space's own folder, then the package's own folder.

"Nearest wins" applies per field, not per layer. A farther layer still supplies everything the nearer ones leave unset. 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. Only layers 3 and 6 apply over the root defaults, making it 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 because layer 6 beat layer 1. It runs with revertOnFail on from layer 4. Its changelog sits in HISTORY.md from 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 depends on that folder. This covers most repositories without any configuration at all.

Some packages declare no name dispat can read. A Gradle module keeps its coordinate in a build script that is a program rather than a manifest, and a folder built by a Makefile declares nothing. A project in an ecosystem dispat lacks a 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.

Set manifestNames to 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. This works 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 this honest. A stated name outranks one a manifest declares, because you are stating a fact rather than a file happening to say it.

No two packages may state the same name. A manifest name identifies one package. A collision here is a typo in your configuration, so dispat fails to load it.

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

src

The files a commit touches attribute it when it names no scope. Whichever package owns a changed path is the package the commit addresses. Ownership means the package folder, so everything in the folder counts.

This is usually right, but occasionally it is not. A package whose folder also holds a docs site, a fixtures tree, or a scratch directory releases on a typo fix in prose.

Set src to narrow 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.

You should know what src does not change, 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. 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 that runs when a commit names no scope at all. See scope sets.

dispat refuses a src that could never match at load. This includes 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 this check prevents a package from quietly stopping its releases.

You can also write src on a space or at the root, where it becomes the default for every package it reaches. dispat still resolves it against each package's own folder. See ignore to exclude some files rather than pick one folder, and the two work together.

Package weights: concurrency

A package entry's concurrency is a weight, written as a scalar or a [build, publish] pair, defining 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, because a weight has no CPU reading.

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

Standalone packages: path

Add a path to an entry to create a package outside every space. This could be 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 without absolute paths or .., 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. dispat builds its effective configuration through the same layers as an override, starting from an empty base instead of a space, then applying the package's own in-folder file field by field.

Having no space has three consequences:

  • 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.

The loader lowercases config map keys. This means a standalone package's name 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. This keeps its dependencies next to the rest of its configuration:

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

These entries match the ones a consumer lists in the top-level dependencies object. An edge reads the same wherever you declare it, and moving one between the two places is a cut and a paste. The consumer is the package itself, and one provider needs no array: "dependencies": "core".

All declarations merge into one list. This includes the top-level object, every entry's list, and every in-folder list. Where you declare an edge 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. It removes a stale edge declared in a space's packages entry from the root config, one declared in a space file 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. dispat applies a kind correction 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. It uses 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, because a file cannot move the folder it lives in.

This file is the most local layer, the last rung of the ladder. The same merge rules apply, and dispat rejects unknown keys with the file named. The file travels with the package, so a package moved between spaces keeps its exceptions.

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

dispat refuses a package folder's file that declares spaces or packages and prints guidance. The folder holds a monorepo root of its own, like a vendored or nested repository, and you must exclude it via .dispatexclude rather than half-merge it. A space folder's file is the one place packages belongs outside the root config, which you can read about in 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 file to the monorepo root. This means cd packages/core && dispat lint works whatever the folders on the way carry.