Recovering from a failed run
A repeat dispat run plans from durable tags: it skips recorded packages and continues with their pending
consumers. Check which record failed before retrying, because a package can have its native tag while its separate
GitHub release record is still missing.
There is one interval a tag cannot describe: a publish command may succeed and the process may stop before dispat writes its tag. If a run is killed during that interval, inspect that package's registry or destination before you retry. dispat does not claim exactly-once delivery across an arbitrary shell command.
Removing a release page or Git tag does not recall artifacts already downloaded or cached by package proxies. After an authorized withdrawal, verify each publication destination separately and account for consumers that may still have the withdrawn bytes. A removed remote reference alone is not evidence that a publication was undone.
Recover an npm publication without a release tag
Treat npm's version and dispat's release tag as separate records. If npm accepts a tarball and the run stops before the Git tag reaches the remote, do not create the missing Git tag or npm dist-tag by hand. First inspect the exact npm version, the workflow revision, and the tarball integrity outside the publish script.
If a workflow is interrupted after npm reports success and before Git records are written, inspect npm separately.
A project whose publisher intentionally runs only npm publish leaves that decision to the workflow operator;
rerunning the same publish command is unsafe. npm does not permit a name and version combination to be reused after
publication, including after an unpublish. Recover forward with a new patch version through the reviewed workflow, while keeping any independent
binary or provider pin unchanged when its bytes did not change. For example, reserve 1.10.0 and release 1.10.1
rather than trying to recreate 1.10.0.
Keep artifact validation before publication: build and inspect one tarball, verify its package name, version, and digest, exercise that exact file, then give the same path to npm. The publish stage can then remain one upload operation. If the entire package and all its versions are unpublished, npm also requires waiting 24 hours before publishing another version of that package. Use npm's authenticated procedure and follow its unpublish policy as a separate administrative action; removal does not make its version reusable.
The 24-hour restriction applies to every new version under the removed package name. Advancing the patch version
does not bypass it. A registry E409 indicating that the package is still being processed can occur during this
interval; inspect the package's time.unpublished.time and wait until the restriction has expired before retrying CI.
After a successful publication, independent installation checks may need to wait for registry metadata to become available. Poll for the exact version with a bounded deadline before installing it. Keep that readiness check in a post-release job, after release records, so a delayed registry response cannot turn an accepted upload into a failed publish stage. This short readiness wait does not replace the 24-hour restriction after complete removal.
Publish the next patch before removing the previous version
If the package is still published, an alternative is to keep its name and publish the next patch through the reviewed
workflow first. For example, publish 1.10.1, verify its release records and installation, then unpublish only
1.10.0 using npm unpublish <package-name>@1.10.0, if npm's
unpublish policy permits it. Keep 1.10.1 published: removing only the
previous version avoids complete package removal and its 24-hour restriction, without renaming the package.
Exact dependency pins and lockfiles that still require 1.10.0 may fail to install after removal; update those
consumers to the replacement patch. If removal is not permitted or would disrupt consumers, deprecate only the old
version with a message directing users to the patch. The removed version remains unusable for future publications.
If all versions were already unpublished, wait out the restriction described above before publishing the patch.
Continue after a dependent build fails
In this example, core and its consumer app release together. The tests for app break its build after core
already published.
$ git commit -m "feat(core)^: new API, reaching the app"
$ dispat
12:04:05 INF ● changed bump=minor channel=stable dueToProviders=[] ownCommits=1 package=core reason=direct space=libs version="0.0.0 -> 0.1.0"
12:04:05 INF ● changed bump=patch channel=stable dependsOn=["core"] dueToProviders=["core"] ownCommits=0 package=app reason="propagated from core" space=libs version="0.0.0 -> 0.0.1"
12:04:05 INF release plan ready held=0 packages=2 releasing=2
12:04:05 INF build started package=core stage=build version=0.1.0
12:04:05 INF build succeeded package=core stage=build version=0.1.0
12:04:05 INF publish started package=core stage=publish version=0.1.0
12:04:05 INF version succeeded package=app stage=version version=0.0.1
12:04:05 INF build started package=app stage=build version=0.0.1
12:04:05 INF tests failed package=app stage=build version=0.0.1
12:04:05 ERR build script failed error="exit status 1" package=app stage=build version=0.0.1
12:04:05 INF published package=core stage=publish tag=core@0.1.0 version=0.1.0
12:04:05 INF summary channel=stable package=core status=published tag=core@0.1.0 took=1.2s version="0.0.0 -> 0.1.0"
12:04:05 ERR summary error="build: exit status 1" channel=stable failedStage=build package=app status=failed took=1.2s version="0.0.0 -> 0.0.1"
12:04:05 INF done cancelled=0 failed=1 held=0 published=1 skipped=0 took=1.2s unchanged=0
$ git tag
core@0.1.0
The run exits with a non-zero status, leaving core@0.1.0 tagged while app is not. You do not need to repair a state
file or reconcile versions by hand. Fix the tests and run the command again:
$ dispat
12:04:05 WRN catch-up release at 0.0.1: discharging work already published by core@0.1.0 code=W193 package=app
12:04:05 INF plan diagnostics errors=0 warnings=1
12:04:05 INF unchanged channel=stable package=core space=libs version=0.1.0
12:04:05 INF ↻ catch-up bump=patch channel=stable dependsOn=["core"] dueToProviders=["core"] ownCommits=0 package=app reason="catch-up from core" space=libs version="0.0.0 -> 0.0.1"
12:04:05 INF version succeeded package=app stage=version version=0.0.1
12:04:05 INF build succeeded package=app stage=build version=0.0.1
12:04:05 INF published package=app stage=publish tag=app@0.0.1 version=0.0.1
12:04:05 INF done cancelled=0 failed=0 held=0 published=1 skipped=0 took=1.2s unchanged=1
The second run recomputes the plan from history and configuration, sees core@0.1.0 is already recorded, and executes
only the missing half. The W193 marker confirms app is releasing at the exact version it was owed from the earlier
run, while core is never re-released.
Continue after a provider fails
If the provider fails instead, a consumer whose only reason to release was that provider is skipped and reported with
W194, so you can catch it up with a re-run once you fix the provider.
A consumer that has changes of its own is a different case, and by default it releases anyway: its own work is not invalidated by somebody else's failure. What it publishes names the provider's previous version, because that is the version the provider actually has. dispat reconciles its manifests back to that version before it publishes, whatever the version stage wrote earlier while the provider's publish was still pending.
That consumer is not finished with the provider, though. The provider still owes it the release its caret asked for, so the run that finally publishes the provider releases the consumer a second time to pick it up:
$ dispat
12:04:05 INF ● changed bump=minor dueToProviders=[] ownCommits=1 package=core reason=direct version="0.1.0 -> 0.2.0"
12:04:05 INF ● changed bump=patch dependsOn=["core"] dueToProviders=["core"] ownCommits=0 package=app reason="propagated from core" version="0.2.0 -> 0.2.1"
12:04:05 INF done cancelled=0 failed=0 held=0 published=2 skipped=0 unchanged=0
The app package has no commits of its own this time. It is in the plan because the contribution core propagated to
it has still not been delivered by any release of core, and its 0.2.1 release is what delivers it: the range in its
manifest moves to ^0.2.0 and the two packages are in step again. If core fails a second time, app is skipped with
W194 instead, because by then the failure is the only reason it is in the plan at all.
The provider's successful run need not include the consumer, but it must not release the provider alone on the
consumer's own release commit. If app published its own fix at the commit where core failed, and you now run
dispat release --package core on that same commit, dispat refuses before anything runs
(E201): both tags would sit on one commit, and nothing could
later tell that app came first. Release both in one run with dispat release --package core,app, or commit first and
then release core alone. The provider's tag then lands past app's release, and the next full run's dispat status
shows app as a W193 catch-up from the newly published core, even though app's earlier tag already contains the
original propagation commit. Run the full release through CI to deliver that catch-up; no new release-intent commit is
needed.
The same state can arise without a selection: the run releases core and then app on app's own release commit,
and app fails after core published. dispat reports that as E201 as well, because no later plan can find the
debt, and names the remedy: an empty commit release(app) with the footer Release-As: <version>, the version the
failed run planned for app. The next release ships app at that version and picks core up.
One shape cannot be repaired in place. If the consumer's build had already run over the manifests naming the provider's
planned version, whatever it produced may carry that version inside it, and rewriting a manifest does not reach a built
artefact. dispat blocks that consumer with W194 naming the provider rather than rebuilding it silently, and the next
run builds it against what the provider published.
An interrupted run follows the same rule. If you press Ctrl-C or a CI job dies, packages with a completed publish keep
their record. dispat reports everything else as cancelled, and your next run recomputes the remaining plan.
After an ordinary cancellation, dispat gives completed publishes up to five minutes, counted from the interrupt, to
finish their commit, tags, push, and GitHub release record. The same holds whenever the interrupt arrives: during the
build of another package, during postAll, during a commit or push hook, or while the release push is on the wire.
A user hook that is running when the interrupt arrives is stopped, and no later hook starts, while the commit, the
tags and the push it brackets go on. A run that is never interrupted has no such limit. A timeout is reported as a
recording failure (E223), with the local tags and commits left available for inspection. The run still exits
non-zero and its closing webhook says interrupted.
A fleet released from a control repository recovers by the same rule, with the records spread across the repositories that own them. Each successful package is tagged in its owner after publishing, already recorded successes survive a failure elsewhere, consumers of a failed provider stay blocked, and the next run reconstructs the remaining plan from those source tags. A recording error is reported separately and never turns an unrecorded publish into a success. The control-repository page states what to do when a source tag is durable but the control checkpoint failed.
If release commits are enabled, commit or stash pre-existing changes in selected package folders and commit.include
paths before retrying; dispat refuses them so its automatic commit cannot capture unrelated work. It likewise refuses
pre-existing changes only in selected package folders where revertOnFail could reset them. With both features off,
writer output left by the earlier run remains valid input to the retry.
Catch-up has a manifest half as well as a release half. A consumer that failed to release beside its provider keeps its
old range until its own next release, when the version stage reconciles the range to the provider's published version
(W197). This pickup is
the auto-version reconciliation, not a
release triggered by the provider.
What a failed run leaves in the working tree
With release commits enabled, a package that is skipped after an earlier stage of it ran has its folder restored
before the run ends, whether or not revertOnFail is set. The run proved that folder clean when it started, so every
change in it names a version that did not publish, and the release commit does not stage it. Tracked files are
restored from HEAD and untracked files are removed, exactly as revertOnFail does, so the retry starts without
refusing that folder over pre-existing changes. Two folders are left as the run left them, because restoring them
would reach files another package owns: a package rooted at the repository root, and a package whose folder holds
another package's folder.
A package that failed keeps the revertOnFail rule: its folder is
restored only when the setting is on, so you can inspect what its stages wrote. The one exception is a release whose
shared files have to be re-synchronized, described below, which restores the failed package's tracked files and keeps
its untracked ones. Without release commits nothing is restored by default, because nothing proved the folders clean
before the run.
In a fleet, the same rule applies to each repository that makes release commits, and the folder is restored inside the repository that owns it.
Shared files of the release commit
A commit.include path such as a root lock file is shared by every package.
A whole-workspace regenerator in a syncLock script writes every
package's manifest version into it, including the planned version of a package that later fails or is skipped. When a
package whose version or syncLock stage ran does not publish while another package does, dispat therefore
re-synchronizes the shared paths before the release commit:
- The tracked files of every such package are restored to HEAD, so that its manifests name the version that exists. Its untracked files stay for inspection.
- The
commit.includepaths are restored to HEAD. Every published package's folder and changelog file keep what the run wrote. - The
syncLockscripts of every published package whosesyncLockstage ran commands run again, one package at a time in dependency order. Their environment lists each package that did not publish at its previous version withDISPAT_WORKSPACE_<KEY>_RELEASING=false, and leaves it out ofDISPAT_UPDATED_*.
The release commit then records only versions that published. When every package that prepared its release files published, none of this happens.
If a script of step 3 fails, or the run is interrupted before step 3 finishes, dispat restores the commit.include
paths to HEAD again, because a regenerator stopped halfway can leave a file half written, and makes the release commit
without them. The published packages are still committed and tagged. The run reports E223 with the paths and the
packages whose scripts regenerate them. Run those scripts yourself and commit the paths, for example with the script
name the space's autoVersion.syncLock lists:
dispat run npm-install --package web --since all
git add pnpm-lock.yaml
git commit -m "chore(release): regenerate the lock file"
The re-synchronization covers a single repository history. A fleet commits each package's commit.include paths into
that package's own release commit while the run is still going, so no later step can take a package's write back out
of a sibling's commit. In a fleet, check a shared file that a package which did not publish may have written, and
regenerate it before the next release.
Repair GitHub metadata after the package was recorded
A GitHub API error can happen after a successful publish, source tag, and (in a fleet) control checkpoint. An ordinary
dispat release retry sees the source tag and skips that package: it does not republish it, but it also does not
recreate the missing GitHub release. Verify the exact published version and source tag, the control checkpoint if one
was required, and the absence of the GitHub release before repairing only the metadata.
Run the standalone GitHub step in a reviewed CI repair job using the original run's configuration, credentials,
DISPAT_PACKAGE, DISPAT_NEW_VERSION, and DISPAT_TAG. The --since all window includes the already-tagged package.
For a package covered by github.allPackages, the CI command can be:
DISPAT_PACKAGE=core DISPAT_NEW_VERSION=0.1.0 DISPAT_TAG=core@0.1.0 \
dispat github --package core --since all
Use the actual package, version, and tag from the failed run. If the original GitHub release was opted in through
DISPAT_EXPORT_GITHUB, restore that variable and make its original attachment files available to the CI job as well.
Check that the GitHub release names the existing tag and contains the intended assets. Do not run a new publication,
create or move a tag, or choose a new version to repair metadata; do not run this production repair from a local
workstation. An existing GitHub release is skipped by the standalone step (W224).
When somebody pushes while the release runs
The behind-remote guard closes before the plan is computed, so it cannot cover a commit pushed after the run has started. Such a commit reaches the release at the very end, as a rejected push, by which point the packages have already published: refusing there would leave a released package with no commit, no tag and nothing on the remote.
dispat therefore recovers rather than refuses, and it does so by joining the two rather than choosing between them. It
pulls the branch and merges what landed with its release commit, leaving that commit exactly as it was, and pushes the
merge. The run exits 0 and says what it did:
12:04:05 WRN pulled the branch during the release to sync changes that landed while it ran; the release tags point at the tree that was planned and the release commit was merged on top branch=main code=W242 remote=origin
Nothing dispat made is rewritten. The release commit keeps its identity and its tags keep naming it, so the tagged tree
is still the one the release recorded: the changelog entries it wrote and the version rewrites it made are inside it,
and anything resolving the tag gets what was published. Only the branch tip changes, into a merge whose first parent is
the release commit and whose second is what arrived. The merge itself is a chore(release) commit, and
nonPackageScopes exempts that scope, so it names no package.
The commit that arrived is outside the tag's ancestry, which is where it belongs. It was not in this run's plan, it is not in this run's records, and the next run plans it and releases it on its own terms, with an entry of its own.
The window this recovers from is still open while it recovers, so another clone can land a commit between the pull and the push that follows it. dispat answers that the same way, up to three rounds in all; the warning is reported once per round. What never moves across the rounds is the tag: it names the commit the run planned, not the merge above it.
The warning is there because the branch the release went out on is not the branch the run was planned against. Read it as a prompt to check that the merge is the history you wanted.
dispat stops before merging anything when the remote already carries a release tag this run is about to push:
12:04:05 ERR push failed code=E224 error="commits landed on origin/main during the release, and origin already carries core@1.2.0: this checkout planned a version that is already published, so pushing again would move a released tag. Pull and run again" remote=origin
That is a checkout whose tags were stale enough for the plan to recompute a version somebody else has already released. Pull and run again instead: the plan then reads the tag that exists and releases the version after it. Moving aliases are not part of the check, because moving them is what every release does.
A checkout that stale is normally refused much earlier. Before it plans anything, a release that pushes reads the
release records of the remote it records to, under its lock, and stops with E196 when the remote holds one this
checkout does not (see plan errors); the remedy is git fetch --tags. This check is the last
line, for a record that arrived while the run was working. Whichever of them reports it, no release tag is ever
replaced on the remote: the push creates the name or leaves what is there alone.
When what landed conflicts
Sometimes the commits that arrived changed the same content the release did. The release has already published by then, so stopping would still leave it nowhere but the local clone. dispat completes it and hands you the conflict instead:
12:04:05 WRN commits landed on the branch during the release and changed the same content; this release's side was kept and theirs was pushed to a branch of its own to be reconciled branch=main code=W243 keptAt=release-conflicts/core-0.1.0-20260902-053012 paths=["packages/core/CHANGELOG.md"] remote=origin
Three things happen, and the run then exits 0:
- This release's side wins every conflicting file. It is the tree that was planned, built, published and tagged, and the tag already names it, so taking the other side would publish content the release never saw. Everything the arriving commits changed that did not conflict is in the merge exactly as it is on the clean path.
- The other side is kept. dispat pushes the foreign tip to a branch of its own, named
release-conflicts/<package>-<version>...-<UTC timestamp>: every package this leg released with its version, then when it happened, so the name says what it belongs to and cannot collide. The branch is never forced, and a name that somehow exists already stops the run rather than being overwritten. - Both records say so. The changelog entry and the GitHub release body carry a note naming the conflicting files and that branch.
The changelog note lives in the merge commit rather than in the release commit, because the release commit is tagged and a tag whose commit is amended names nothing. The tree the tag points at therefore carries the entry without the note; the branch carries it with one. The GitHub release body has no such constraint, since it is written after the push.
What is left is a job for a person. Two versions of the same content exist, one on the branch and one on the quarantine branch, and no rule dispat could follow decides which should survive. Note also that the arriving commits sit outside the tag's ancestry, so the next run releases them as it would any other commit: it will release the parts of their work that did merge, while the conflicting hunks are still only on the quarantine branch. Reconcile the two sides before or after that release, whichever suits, but do not leave the branch unread.
dispat stops with E224 only when the recovery machinery itself fails: the quarantine branch cannot be pushed, the
settled merge cannot be committed, or the merge stopped for a reason that is not conflicting content. The run then
exits non-zero, no tag reaches the remote, the working tree is left out of the merge dispat started, and the
release lock is given back as it is on every other way out.
Read Concepts to understand the properties that make this safe and why dispat needs no state file. You can look up each diagnostic code in Diagnostic codes.