Skip to main content
Version: 1.0

writer: the manifest writer

github.com/yohimik/dispat/pkg/writer bumps versions in package.json, go.mod, Cargo.toml and every other format the scanner reads, in place and format-preserving: only the version text being changed is replaced, and every other byte of the file survives verbatim, including indentation, key order and comments.

Every format writer goes through one internal splicer, so the read cap, the splice, the proof that the result still parses and the atomic write happen in the same place for all of them. Writes go through a same-folder temporary file, an fsync and a rename, and are skipped entirely when nothing changed.

go get github.com/yohimik/dispat/pkg/writer

Rewriting a manifest

res, err := writer.Rewrite("packages/web/package.json", "1.3.0", []writer.Edit{
{Name: "@acme/core", Kind: "dependencies", Range: "^1.3.0"},
})
// res.Applied, res.Skipped, res.Missing, res.VersionWritten, res.Path

The result separates three outcomes, and the distinction is the reason to read it rather than just the error. Applied holds the edits that changed the file. Skipped holds edits whose dependency is declared but whose version cannot be written, which is the normal state of a healthy manifest. Missing holds edits the manifest does not declare at all, which usually means the caller and the file disagree about what is installed.

An unsupported path gives ErrUnsupportedManifest, and reads are capped at 16 MiB (ErrManifestTooLarge).

What is never written

A value that defers to something outside the file is left alone rather than replaced with a literal, because overwriting it would sever the indirection it exists for. That covers a Maven ${property}, a Cargo { workspace = true }, an MSBuild or Xcode $(...) property, a nuspec $version$ pack-time token, and the spec.version = Acme::VERSION constant nearly every published gem uses.

A Maven <parent> version is never written either: it selects which POM this one inherits from, so moving it would repoint the build rather than release the module. Nothing is added where a declaration carries no version on purpose, such as a gem pinned to a git revision.

Every writer proves its output before a byte lands, re-parsing the result with json.Valid, modfile.Parse, a re-parse for the XML formats or toml.Unmarshal. The formats with no cheap grammar to re-parse against, meaning project.pbxproj, the Ruby manifests and the Gradle build scripts, are guarded three ways instead: a replacement carrying any byte that could end a literal or open a block is refused, the file's brace balance must come out unchanged, and the reader has to agree that every splice landed where it was aimed.

Replacing literal text

Some versions do not live in a manifest at all: a coordinate a Gradle script assembles by hand, an install line in a README. Replace is the same machinery with the format knowledge taken away.

res, err := writer.Replace("build.gradle", []writer.Replacement{
{Find: "com.acme:core:1.2.0", Write: "com.acme:core:1.3.0"},
})
// res.Applied, res.Skipped, res.Missing, res.Count, res.Path

It runs over any file, replaces every occurrence rather than the first, and applies replacements in order, each over what the one before it left. It refuses a file that looks binary (ErrBinaryFile, decided by a NUL byte in the first 8 KiB) so a replacement never lands inside a PNG that happens to contain the version text. ReplaceBytes is the same work in memory.

Because it parses nothing, it cannot tell an intended match from an accidental one. A replacement should carry enough context to be unambiguous: com.acme:core:1.2.0, not 1.2.0.

Redirecting a dependency to a local folder

Relink manages the directive that points a dependency at a folder in the same repository instead of at a registry:

res, err := writer.Relink("services/svc/go.mod", []writer.Link{
{Name: "github.com/acme/core", Path: "../../pkg/core"},
})

An empty Path removes the redirect instead of adding one, which is what a release does before publishing: a local link that ships to consumers gives them a module they cannot resolve.

Five formats have such a directive: go.mod (replace), Cargo.toml ([patch.crates-io]), pubspec.yaml (dependency_overrides), pyproject.toml ([tool.uv.sources]) and package.json (overrides, resolutions or pnpm.overrides, chosen by reading the file rather than guessing). SupportsLink reports the same five at runtime, and every other format writes nothing and reports each link in Skipped.

Links reads the other direction, enumerating the directives a file already carries, so a CI gate can prove no local link survived a build without knowing any names in advance. DropLinks is Links followed by the matching removals:

links, err := writer.Links("services/svc/go.mod")
res, err := writer.DropLinks("services/svc/go.mod")

Writing the build counter

The version rewriters never touch a build counter, since it is a monotonic count rather than a semantic version and the two move for different reasons. SetBuild is the separate write that moves it:

res, err := writer.SetBuild("ios/App/Info.plist", "42")
// res.BuildWritten, res.Path

It covers the five places a format keeps one: CFBundleVersion, android:versionCode, CURRENT_PROJECT_VERSION, Gradle's versionCode and the + suffix a pubspec version carries. A counter the file does not declare is not created, and a format with no counter at all gives ErrNoBuildCounter.

The same work from the command line

dispat writer and dispat replacer are this package with a report attached, and neither needs a configuration file or a git repository:

dispat writer packages/web/package.json --set-version 1.3.0
dispat writer services/api/go.mod --link github.com/acme/core=../core
dispat replacer --replace 'com.acme:core:1.2.0=>com.acme:core:1.3.0' build.gradle

Inside a release, autoVersion is the same writer applied to the planned versions at the version stage.

Further reading