FerrFlow supports six config file formats, searched in this order:

  1. ferrflow.json
  2. ferrflow.json5
  3. ferrflow.toml
  4. ferrflow.ts (requires tsx)
  5. ferrflow.js (requires node)
  6. .ferrflow (JSON)

If no config file is found, FerrFlow auto-detects common version files in the current directory.

Config formats

TypeScript

export default {
  workspace: {
    tagTemplate: "v{version}",
  },
  package: [
    {
      name: "my-app",
      path: ".",
      changelog: "CHANGELOG.md",
      versionedFiles: [
        { path: "Cargo.toml", format: "toml" },
      ],
    },
  ],
};

JSON

{
  "$schema": "https://ferrflow.com/schema/ferrflow.json",
  "workspace": {
    "tagTemplate": "v{version}"
  },
  "package": [
    {
      "name": "my-app",
      "path": ".",
      "changelog": "CHANGELOG.md",
      "versionedFiles": [
        { "path": "Cargo.toml", "format": "toml" }
      ]
    }
  ]
}

TOML

[workspace]
tag_template = "v{version}"

[[package]] name = "my-app" path = "." changelog = "CHANGELOG.md"

[[package.versioned_files]] path = "Cargo.toml" format = "toml"

JSON5

{
  $schema: "https://ferrflow.com/schema/ferrflow.json",
  workspace: {
    tagTemplate: "v{version}",
  },
  package: [
    {
      name: "my-app",
      path: ".",
      changelog: "CHANGELOG.md",
      versionedFiles: [
        { path: "Cargo.toml", format: "toml" },
      ],
    },
  ],
}

TypeScript and JavaScript configs

TypeScript (.ts) and JavaScript (.js) config files use a default ESM export. The export can be a plain object or an async function.

The main advantage of TS/JS configs is function hooks. Instead of shell command strings, you can write hooks as native functions with full access to the hook context:

export default {
  workspace: {
    tagTemplate: 'v{version}',
    hooks: {
      postPublish: async (ctx) => {
        await fetch('https://hooks.slack.com/services/...', {
          method: 'POST',
          body: JSON.stringify({
            text: `Released ${ctx.package}@${ctx.newVersion}`,
          }),
        });
      },
    },
  },
  package: [
    {
      name: 'my-app',
      path: '.',
      versionedFiles: [{ path: 'package.json', format: 'json' }],
    },
  ],
};

Hook context object

Function hooks receive a context object with these fields:

Field Type Description
package string Package name
oldVersion string Version before bump (empty on first release)
newVersion string Version after bump
bumpType string major, minor, patch, or none
tag string Full git tag name
dryRun boolean Whether --dry-run is set
packagePath string Absolute path to package root
channel string or null Pre-release channel name
isPrerelease boolean Whether this is a pre-release
monorepo boolean Whether this is a monorepo release
changelog string Rendered changelog section for this bump (markdown)
commits array { hash, message, type?, scope?, breaking } per commit in the bump
bumpedFiles array { path, format } for each file the release modified
allPackages array { name, version, bump } for every package released in this batch
releaseUrl string or null URL of the created forge release — postPublish hooks only, null otherwise

commits, bumpedFiles and allPackages arrive as real arrays (parsed from JSON), so you can iterate them directly:

export default {
  workspace: {
    hooks: {
      postBump(ctx) {
        for (const c of ctx.commits) {
          if (c.breaking) console.log(`breaking: ${c.message}`);
        }
      },
    },
  },
};

Shell string hooks and function hooks can be mixed in the same config. Shell strings still work in TS/JS configs.

workspace

Global settings that apply to all packages.

Field Type Default Description
remote string "origin" Git remote to push to
branch string auto-detected Branch to push to (detected from remote HEAD)
tagTemplate string "v{version}" or "{name}@v{version}" Tag naming pattern. Uses {version} and {name} placeholders. Defaults to v{version} for single-package repos and {name}@v{version} for monorepos.
versioning string "semver" Default versioning strategy for all packages
releaseCommitMode string "commit" How to handle the release commit: "commit", "pr", or "none"
releaseCommitScope string "grouped" In a monorepo where several packages are bumped at once, whether to create a single "grouped" commit or one commit "per-package". Only matters when multiple packages bump.
forge string "auto" Git forge override: "auto" detects from the remote URL, and for an unrecognised host it probes the API over HTTPS to auto-detect a self-hosted GitLab, GitHub Enterprise, or Gitea / Forgejo instance (cached, ~2s, best-effort). Set "github", "gitlab", "gitea" (Gitea / Forgejo / Codeberg), or "bitbucket" (Bitbucket Cloud) to force a forge — needed only when the host isn't reachable over HTTPS or you want to skip probing. Gitea auth uses GITEA_TOKEN / FORGEJO_TOKEN; Bitbucket uses BITBUCKET_TOKEN. All cover release creation — on Bitbucket, which has no release object, the release is the annotated tag FerrFlow pushes. PR mode is GitHub/GitLab only.
skipCi boolean depends on mode Add [skip ci] to release commits. Defaults to true when mode is "commit", false otherwise.
commitSkipMarkers array ["[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"] Markers that cause FerrFlow to skip a commit when computing the next version. Matched case-insensitively, subject line only.
autoMergeReleases boolean true Enable auto-merge on release PRs (only applies when mode is "pr")
recoverMissedReleases boolean false Compare versioned files against the last tag instead of just the last commit, recovering releases missed earlier in a monorepo.
updateLockfiles boolean false After a bump, refresh the sibling lockfile (Cargo.lock, package-lock.json / pnpm-lock.yaml / yarn.lock, poetry.lock / uv.lock, Gemfile.lock, mix.lock) via the package manager's offline / lockfile-only mode and stage it in the same release commit. A missing package manager or an unresolvable offline update is warned about, never fatal. Set per-package updateLockfiles: false to opt a single package out.
updateDependents boolean false After a bump, rewrite the version constraint every dependent declares for the bumped package and stage the manifest in the same release commit. Only json (dependencies, devDependencies, peerDependencies, optionalDependencies) and toml (dependencies, dev-dependencies, build-dependencies) manifests are rewritten, and only plain operator + version constraints — the operator is preserved (^1.2.3^2.0.0). A workspace:*, file:/git: spec, 1.x or a multi-part range is left untouched.
floatingTags array [] Floating tag levels to create on each release: "major", "minor". For example, ["major"] creates a v1 tag that always points to the latest v1.x.y release.
orphanedTagStrategy string "warn" How to handle tags pointing to orphaned commits after rebase + force-push: "warn", "treeHash", or "message"
branches array [] Map branches to pre-release channels (see Pre-release channels).
linked array [] Groups of packages that share a version line when co-released. When any member has a releasable commit, all members bump to the same (highest) version (see Linked and fixed version groups).
fixed array [] Groups of packages locked to an identical version forever. Behaves like linked; ferrflow validate warns when a fixed group's versions have drifted apart.
anonymous_telemetry boolean true Deprecated and ignored — telemetry was removed in v5.33 (details). The key (and its telemetry alias) stays accepted so existing configs remain valid.

Tag template

The tagTemplate field controls how git tags are named. Available placeholders:

Placeholder Description
{version} The version number (e.g. 1.2.3)
{name} The package name

JSON

{
  "workspace": {
    "tagTemplate": "v{version}"
  }
}

TOML

[workspace]
tag_template = "v{version}"

JSON5

{
  workspace: {
    tagTemplate: "v{version}",
  },
}

For monorepos, use {name} to namespace tags per package:

JSON

{
  "workspace": {
    "tagTemplate": "{name}@v{version}"
  }
}

TOML

[workspace]
tag_template = "{name}@v{version}"

JSON5

{
  workspace: {
    tagTemplate: "{name}@v{version}",
  },
}

Floating tags

Floating tags are version aliases that always point to the latest release matching a given level. This is useful for GitHub Actions or Docker images where users reference v1 instead of v1.2.3.

JSON

{
  "workspace": {
    "floatingTags": ["major"]
  }
}

TOML

[workspace]
floating_tags = ["major"]

JSON5

{
  workspace: {
    floatingTags: ["major"],
  },
}

When releasing v1.2.3, FerrFlow creates or moves a v1 tag pointing to the same commit. With ["major", "minor"], both v1 and v1.2 tags are maintained.

If a floating tag would move backward (e.g. releasing a v1.1.0 hotfix when v1.2.0 already exists), FerrFlow blocks the release. Use --force to override this check.

Orphaned tag strategy

When a branch is rebased and force-pushed, existing tags may point to commits that are no longer part of the branch history. By default, FerrFlow warns about these orphaned tags and skips them. You can configure automatic recovery instead.

Strategy Behavior
"warn" Log a warning identifying the orphaned tag and skip it (default)
"treeHash" Attempt to find a commit on the current branch with the same file tree as the orphaned tag's commit
"message" Attempt to find a commit on the current branch with the same commit message

JSON

{
  "workspace": {
    "orphanedTagStrategy": "treeHash"
  }
}

TOML

[workspace]
orphaned_tag_strategy = "treeHash"

JSON5

{
  workspace: {
    orphanedTagStrategy: "treeHash",
  },
}

"treeHash" is the safest recovery option — it matches commits that have identical file contents, which is typical after a rebase that doesn't modify files. Use "message" when rebases also change the tree (e.g. squashing commits) but preserve the original message.

If recovery fails (no matching commit found within the last 1000 commits), FerrFlow falls back to warning and skipping the tag. In that case, re-tag manually:

git tag -f api@v1.2.0 <correct-commit>

Release commit mode

Controls how FerrFlow handles the commit that updates version files and changelogs.

Mode Behavior
"commit" Commits directly to the current branch and pushes (default)
"pr" Opens a persistent release pull request and updates it on each new commit
"none" Only creates tags and releases, does not commit file changes

JSON

{
  "workspace": {
    "releaseCommitMode": "pr",
    "autoMergeReleases": true
  }
}

TOML

[workspace]
release_commit_mode = "pr"
auto_merge_releases = true

JSON5

{
  workspace: {
    releaseCommitMode: "pr",
    autoMergeReleases: true,
  },
}

In "pr" mode FerrFlow keeps one long-lived release PR per target branch. It maintains a single release branch — ferrflow/release- — and on every new commit it recomputes the version and changelog and force-pushes that same branch, so the open PR updates in place instead of a new PR opening per version.

autoMergeReleases (default true) enables auto-merge on that PR; it re-applies on each update and is a no-op when disabled (the PR just waits for a human). PR mode is supported on GitHub and GitLab.

FerrFlow won't clobber work you push onto the release branch: if the branch carries a commit it didn't author — anything that isn't a chore(release): commit, such as a review fix you pushed — it warns and leaves the branch and PR untouched for that run.

Versioning strategies

FerrFlow supports multiple versioning strategies, configurable at workspace or package level.

Strategy Format Example progression
semver MAJOR.MINOR.PATCH 1.2.31.3.02.0.0
calver YYYY.MM.PATCH 2026.03.02026.03.12026.04.0
calver-short YY.MM.PATCH 26.03.026.03.1
calver-seq YYYY.MM.SEQ 2026.03.12026.03.2
sequential N 123
zerover 0.MINOR.PATCH 0.1.00.2.0 (never reaches 1.0)

JSON

{
  "workspace": {
    "versioning": "calver"
  }
}

TOML

[workspace]
versioning = "calver"

JSON5

{
  workspace: {
    versioning: "calver",
  },
}

Pre-release channels

The branches array maps branch names (or glob patterns) to pre-release channels. When FerrFlow runs on a branch matching an entry, it releases on that channel — e.g. 1.4.0-beta.1 instead of 1.4.0. The same mapping is what --channel on ferrflow check and ferrflow release overrides ad-hoc.

Each entry has:

Field Type Description
name string Branch name or glob pattern (e.g. "main", "release/*")
channel string or false Channel name ("beta", "rc", …), or false for a stable release
prereleaseIdentifier string Strategy for the identifier appended after the channel name

JSON

{
  "workspace": {
    "branches": [
      { "name": "main", "channel": false },
      { "name": "next", "channel": "beta" }
    ]
  }
}

TOML

[[workspace.branches]]
name = "main"
channel = false

[[workspace.branches]] name = "next" channel = "beta"

JSON5

{
  workspace: {
    branches: [
      { name: "main", channel: false },
      { name: "next", channel: "beta" },
    ],
  },
}

package

Defines a package to version. You can have one or many.

Field Required Default Description
name yes Package identifier, used in git tag prefix
path yes Relative path to the package directory
changelog no {path}/CHANGELOG.md Path to the changelog file
sharedPaths no [] Paths that trigger this package when changed
dependsOn no [] Packages this package depends on. When a dependency is bumped, this package is bumped too — with the same bump type by default. Each entry is a package name, or { "name": "core", "propagate": "patch" } to choose the policy (same, major-on-major, patch, none).
versioning no inherited from workspace Override versioning strategy for this package
tagTemplate no inherited from workspace Override tag template for this package
floatingTags no inherited from workspace Override floating tags for this package

versionedFiles

Files where the version number should be updated.

JSON

{
  "package": [
    {
      "name": "my-app",
      "path": ".",
      "versionedFiles": [
        { "path": "Cargo.toml", "format": "toml" },
        { "path": "npm/package.json", "format": "json" }
      ]
    }
  ]
}

TOML

[[package]]
name = "my-app"
path = "."

[[package.versioned_files]] path = "Cargo.toml" format = "toml"

[[package.versioned_files]] path = "npm/package.json" format = "json"

JSON5

{
  package: [
    {
      name: "my-app",
      path: ".",
      versionedFiles: [
        { path: "Cargo.toml", format: "toml" },
        { path: "npm/package.json", format: "json" },
      ],
    },
  ],
}
format File Field updated
toml Cargo.toml, pyproject.toml [package].version or [project].version
json package.json version
xml pom.xml First element
gradle build.gradle, build.gradle.kts version = "..."
helm Chart.yaml version and appVersion (when present)
gomod go.mod No file update — version comes from git tags only
txt VERSION, VERSION.txt Entire file content replaced

Tag-only packages

versionedFiles is optional. Omit it (or set it to []) for packages whose version is communicated entirely through git tags and GitHub Releases — Go modules, Docker images, GitHub Actions, infrastructure repos.

JSON

{
  "package": [
    {
      "name": "my-action",
      "path": ".",
      "versionedFiles": []
    }
  ]
}

TOML

[[package]]
name = "my-action"
path = "."
versioned_files = []

JSON5

{
  package: [
    {
      name: "my-action",
      path: ".",
      versionedFiles: [],
    },
  ],
}

FerrFlow reads the current version from the latest matching git tag, computes the next bump from conventional commits, then creates the tag, the GitHub Release, the changelog and any floating tags — without touching any source file. Hooks still run normally, so you can docker build, docker push, or gh release upload from postPublish against FERRFLOW_NEW_VERSION.

hooks

Run shell commands at key points in the release lifecycle. Hooks can be defined at workspace level (defaults for all packages) or per package (overrides workspace hooks for that package).

Lifecycle

calculate bump
  ↓
pre_bump        ← validate state, check prerequisites
  ↓
write version files
  ↓
post_bump       ← modify additional files based on new version
  ↓
generate changelog
  ↓
pre_commit      ← review staged changes, run linters
  ↓
git commit
  ↓
post_commit     ← react to the release commit
  ↓
pre_tag         ← smoke-test the bumped tree before the tag lands
  ↓
git tag
  ↓
post_tag        ← cargo publish before push (recoverable if it fails)
  ↓
pre_publish     ← run tests against tagged commit, build artifacts
  ↓
git push + create release
  ↓
post_publish    ← push Docker images, notify Slack, publish packages

pre_release      (PR mode) after the release PR opens, before merge
on_success      ← once, after the whole release completes cleanly
on_error        ← once, when the release fails ($FERRFLOW_ERROR_CODE)

Configuration

JSON

{
  "workspace": {
    "hooks": {
      "preBump": "cargo test",
      "postBump": "node scripts/sync-deps.js",
      "preCommit": "cargo fmt --check",
      "prePublish": "cargo build --release",
      "postPublish": "make docker-push && ./scripts/notify.sh",
      "onFailure": "abort"
    }
  }
}

TOML

[hooks]
pre_bump     = "cargo test"
post_bump    = "node scripts/sync-deps.js"
pre_commit   = "cargo fmt --check"
pre_publish  = "cargo build --release"
post_publish = "make docker-push && ./scripts/notify.sh"
on_failure   = "abort"

JSON5

{
  workspace: {
    hooks: {
      preBump: "cargo test",
      postBump: "node scripts/sync-deps.js",
      preCommit: "cargo fmt --check",
      prePublish: "cargo build --release",
      postPublish: "make docker-push && ./scripts/notify.sh",
      onFailure: "abort",
    },
  },
}
Field Type Default Description
preBump string Run after bump calculation, before writing version files
postBump string Run after version files are written
preCommit string Run after changelog, before git commit
postCommit string Run after the release commit, before tagging
preTag string Run after the commit, immediately before git tag
postTag string Run after tags are created, before push
prePublish string Run after commit+tag, before push
postPublish string Run after push and release creation
preRelease string PR mode only: after the release PR opens, before merge (once per run)
onSuccess string Run once after the whole release completes cleanly
onError string Run once when the release fails; sets FERRFLOW_ERROR_CODE (once per run)
onFailure string "abort" Strategy — "abort" cancels the release on hook failure, "continue" prints a warning

Environment variables

Every hook receives these variables:

Variable Description Example
FERRFLOW_PACKAGE Package name api
FERRFLOW_OLD_VERSION Version before bump (empty on first release) 1.2.3
FERRFLOW_NEW_VERSION Version after bump 1.3.0
FERRFLOW_BUMP_TYPE major, minor, patch, or none minor
FERRFLOW_TAG Full git tag name api@v1.3.0
FERRFLOW_DRY_RUN true if --dry-run is set false
FERRFLOW_PACKAGE_PATH Absolute path to package root /home/user/repo/packages/api
FERRFLOW_IS_PRERELEASE true on a pre-release channel false
FERRFLOW_MONOREPO true on a monorepo release false
FERRFLOW_CHANGELOG Rendered changelog section for this bump ### Features\n- ...
FERRFLOW_COMMITS_JSON JSON array of { hash, message, type?, scope?, breaking } [{"hash":"a1b2","message":"feat: x","type":"feat","breaking":false}]
FERRFLOW_BUMPED_FILES_JSON JSON array of { path, format } the release modified [{"path":"package.json","format":"json"}]
FERRFLOW_ALL_PACKAGES_JSON JSON array of { name, version, bump } released this batch [{"name":"api","version":"1.3.0","bump":"minor"}]
FERRFLOW_RELEASE_URL URL of the created forge release (postPublish only) https://github.com/acme/api/releases/tag/v1.3.0
FERRFLOW_ERROR_CODE Error code, set only for onError E2005

FERRFLOW_COMMITS_JSON, FERRFLOW_BUMPED_FILES_JSON and FERRFLOW_ALL_PACKAGES_JSON are JSON strings — pipe them through jq from shell hooks.

For the once-per-run hooks (preRelease, onSuccess, onError) the per-package variables are empty and FERRFLOW_TAG holds every released tag joined by commas.

onFailure is the failure strategy (abort / continue), not a command. The command that runs when a release fails is onError, which receives the failing FERRFLOW_ERROR_CODE.

Per-package hooks

Package-level hooks replace workspace-level hooks for that package (they are not merged).

JSON

{
  "workspace": {
    "hooks": {
      "preBump": "echo releasing $FERRFLOW_PACKAGE",
      "postPublish": "make notify"
    }
  },
  "package": [
    {
      "name": "api",
      "path": "packages/api",
      "hooks": {
        "preBump": "cargo test"
      }
    }
  ]
}

TOML

[hooks]
pre_bump     = "echo releasing $FERRFLOW_PACKAGE"
post_publish = "make notify"

[[package]] name = "api" path = "packages/api"

[package.hooks] pre_bump = "cargo test"

JSON5

{
  workspace: {
    hooks: {
      preBump: "echo releasing $FERRFLOW_PACKAGE",
      postPublish: "make notify",
    },
  },
  package: [
    {
      name: "api",
      path: "packages/api",
      hooks: {
        preBump: "cargo test",
      },
    },
  ],
}

In this example, the api package runs cargo test for preBump (overriding the workspace echo) but inherits the workspace postPublish hook.

Behavior

  • --dry-run: hooks are printed but not executed.
  • --verbose: hook stdout/stderr is streamed live. Otherwise output is only shown on failure.
  • Files modified by postBump or preCommit hooks are automatically included in the release commit.

publishers

Declarative replacement for the shell-script-in-postPublish-hook pattern. Each entry says "after the GitHub Release is created, push this package to that target." Available since v5.4.

Six built-in kinds cover the common publishing targets:

kind What it does Idempotent on
cargo cargo publish to crates.io or a custom registry "already uploaded" registry response
npm npm publish to npmjs.org, GitHub Packages, or a custom registry "cannot publish over the previously published versions"
docker docker buildx build --push with multi-arch + optional Sigstore docker manifest inspect on each requested tag
helm helm package + helm push to an OCI registry helm show chart on the new version
github-release-asset gh release upload --clobber of a sidecar file always re-uploads (clobber semantics)
webhook Generic POST notifier with {name} / {version} / {tag} / {env:NAME} interpolation none — webhooks are fire-and-forget

All kinds honor --dry-run (print the plan, do nothing) and the crash-resume checkpoint (a re-run after a partial failure picks up where it left off).

Registries

Token credentials are declared once at the workspace level. Each publisher references a registry by name; FerrFlow validates the token env var is exported before invoking the underlying tool.

JSON

{
  "workspace": {
    "registries": {
      "kellnr": {
        "url": "https://kellnr.example.com",
        "tokenEnv": "CARGO_REGISTRIES_KELLNR_TOKEN"
      },
      "gh-packages": {
        "url": "https://npm.pkg.github.com",
        "tokenEnv": "NODE_AUTH_TOKEN"
      }
    }
  }
}

TOML

[workspace.registries.kellnr]
url = "https://kellnr.example.com"
token_env = "CARGO_REGISTRIES_KELLNR_TOKEN"

[workspace.registries.gh-packages] url = "https://npm.pkg.github.com" token_env = "NODE_AUTH_TOKEN"

The token value itself never lives in the config file — only the env-var name does. This keeps ferrflow.json checked-in safely.

Multiple registries per package

publishers is a list and every entry runs on its own, so to publish one package to several registries you add one entry per target — each with its own registry (and therefore its own credentials), each idempotency-checked against that registry.

{
  "package": [
    {
      "name": "mylib",
      "path": "crates/mylib",
      "versionedFiles": [{ "path": "Cargo.toml", "format": "toml" }],
      "publishers": [{ "kind": "cargo" }, { "kind": "cargo", "registry": "kellnr" }]
    }
  ]
}

This publishes mylib to crates.io (public, default token) and the private kellnr registry (its own tokenEnv). The same fan-out works for every kind — npm to npmjs + GitHub Packages, helm to two OCI registries, docker to two image targets (one docker login per host).

Cargo publisher

{
  "package": [
    {
      "name": "ferrlabs-auth",
      "path": "crates/auth",
      "versionedFiles": [{ "path": "Cargo.toml", "format": "toml" }],
      "publishers": [{ "kind": "cargo", "registry": "kellnr" }]
    }
  ]
}

Omit registry to publish to crates.io. allowDirty: true adds --allow-dirty (mirrors cargo publish's own flag). noVerify: true adds --no-verify — set it for multi-crate batch releases so inter-dependent crates don't fail on registry-index propagation timing.

npm publisher

{
  "package": [
    {
      "name": "@ferrlabs/ui-react",
      "path": "packages/react",
      "publishers": [
        { "kind": "npm", "registry": "gh-packages", "tag": "next", "access": "public" }
      ]
    }
  ]
}

Omit registry to publish to npmjs.org. tag defaults to "latest". A scoped .npmrc is written into the package dir for the duration of the publish and removed on exit — your project's .npmrc is never modified.

Docker publisher

{
  "package": [
    {
      "name": "ferrlabs-auth-api",
      "path": "crates/auth-api",
      "publishers": [
        {
          "kind": "docker",
          "image": "ghcr.io/ferrlabs/auth-api",
          "tags": ["{version}", "{major}", "{minor}", "latest"],
          "platforms": ["linux/amd64", "linux/arm64"],
          "sign": "sigstore"
        }
      ]
    }
  ]
}

{version}, {major}, {minor} expand from the release version. sign: "sigstore" runs cosign sign --yes @ against the produced manifest after push. Auth assumes you've already run docker login (FerrFlow doesn't manage docker credentials so it doesn't conflict with the rest of the CI).

Helm publisher

{
  "package": [
    {
      "name": "ferrvault-operator",
      "path": "operator",
      "publishers": [
        {
          "kind": "helm",
          "chart": "chart",
          "registry": "oci://ghcr.io/ferrlabs/charts"
        }
      ]
    }
  ]
}

chart is the directory containing Chart.yaml, relative to the package path. Auth assumes helm registry login was done in CI.

GitHub release asset

{
  "publishers": [
    { "kind": "github-release-asset", "path": "sbom.cdx.json" },
    {
      "kind": "github-release-asset",
      "path": "ferrflow-linux-x64.tar.gz.bundle",
      "displayName": "linux-x64.bundle"
    }
  ]
}

Re-uploads use --clobber so a retry replaces the previous file rather than failing. Picks up GITHUB_TOKEN from the environment, same as gh release upload.

Webhook

{
  "publishers": [
    {
      "kind": "webhook",
      "url": "https://hooks.slack.com/services/...",
      "body": { "text": "Released {name}@{version} :rocket:" },
      "headers": { "Authorization": "Bearer {env:SLACK_TOKEN}" }
    }
  ]
}

{name}, {version}, {tag} and {env:NAME} are interpolated in the URL, body, and header values. Missing {env:NAME} is an error (a webhook with an unset bearer token must NOT send anonymously).

Extra flags (args)

Every command-based publisher — cargo, npm, docker, helm, github-release-asset — accepts an args array. The strings are appended verbatim to the underlying tool invocation, so you can pass options FerrFlow doesn't model natively without waiting for a new field.

{
  "publishers": [
    { "kind": "cargo", "registry": "kellnr", "args": ["--locked"] },
    { "kind": "npm", "args": ["--provenance"] },
    {
      "kind": "docker",
      "image": "ghcr.io/ferrlabs/api",
      "args": ["--build-arg", "PROFILE=release"]
    }
  ]
}

For the docker publisher, args are inserted before the build-context positional (buildx rejects flags that follow the context argument). The webhook publisher has no args — it isn't a shelled-out command.

Deferring publishing to a separate job (deferPublish)

By default publishers run inline at the end of ferrflow release. That's fine when the release job already has what the publishers need — but docker, helm, and npm publishers need a build toolchain (buildx, helm, a built dist/) that a minimal release job often doesn't carry.

Set workspace.deferPublish: true and ferrflow release will skip the publishers; a separate ferrflow publish run executes them. You keep one config file:

{
  "workspace": { "deferPublish": true },
  "package": [
    {
      "name": "my-operator",
      "path": ".",
      "versionedFiles": [{ "path": "go.mod", "format": "gomod" }],
      "publishers": [
        { "kind": "docker", "image": "ghcr.io/acme/my-operator", "tags": ["{version}", "latest"] },
        { "kind": "helm", "chart": "charts/my-operator", "registry": "oci://ghcr.io/acme/charts" }
      ]
    }
  ]
}

release versions + tags as usual; a tag-triggered job that sets up the toolchain runs ferrflow publish (which always runs the publishers, ignoring deferPublish). See the ferrflow publish reference for the matching workflow.

Migrating from postPublish hooks

The publishers block is additive — your existing postPublish hooks keep working. Recommended order:

  1. Add a publishers block alongside your hook with --dry-run enabled in CI to preview what would be published.
  2. Once the dry-run output matches what your hook does, switch CI to a real ferrflow release and verify the live publish.
  3. Delete the postPublish hook.

The crash-resume checkpoint (see pipeline triggers) means a partially-failed publish is safely re-runnable — the publishers that already succeeded skip themselves on the second pass.

Complete examples

Single repo

JSON

{
  "$schema": "https://ferrflow.com/schema/ferrflow.json",
  "workspace": {
    "tagTemplate": "v{version}"
  },
  "package": [
    {
      "name": "ferrflow",
      "path": ".",
      "changelog": "CHANGELOG.md",
      "versionedFiles": [
        { "path": "Cargo.toml", "format": "toml" },
        { "path": "npm/package.json", "format": "json" }
      ]
    }
  ]
}

TOML

[workspace]
tag_template = "v{version}"

[[package]] name = "ferrflow" path = "." changelog = "CHANGELOG.md"

[[package.versioned_files]] path = "Cargo.toml" format = "toml"

[[package.versioned_files]] path = "npm/package.json" format = "json"

JSON5

{
  $schema: "https://ferrflow.com/schema/ferrflow.json",
  workspace: {
    tagTemplate: "v{version}",
  },
  package: [
    {
      name: "ferrflow",
      path: ".",
      changelog: "CHANGELOG.md",
      versionedFiles: [
        { path: "Cargo.toml", format: "toml" },
        { path: "npm/package.json", format: "json" },
      ],
    },
  ],
}

Monorepo

JSON

{
  "$schema": "https://ferrflow.com/schema/ferrflow.json",
  "workspace": {
    "tagTemplate": "{name}@v{version}"
  },
  "package": [
    {
      "name": "api",
      "path": "packages/api",
      "changelog": "packages/api/CHANGELOG.md",
      "sharedPaths": ["packages/shared/"],
      "versionedFiles": [
        { "path": "packages/api/Cargo.toml", "format": "toml" }
      ]
    },
    {
      "name": "site",
      "path": "packages/site",
      "changelog": "packages/site/CHANGELOG.md",
      "sharedPaths": ["packages/shared/"],
      "versionedFiles": [
        { "path": "packages/site/package.json", "format": "json" }
      ]
    }
  ]
}

TOML

[workspace]
tag_template = "{name}@v{version}"

[[package]] name = "api" path = "packages/api" changelog = "packages/api/CHANGELOG.md" shared_paths = ["packages/shared/"]

[[package.versioned_files]] path = "packages/api/Cargo.toml" format = "toml"

[[package]] name = "site" path = "packages/site" changelog = "packages/site/CHANGELOG.md" shared_paths = ["packages/shared/"]

[[package.versioned_files]] path = "packages/site/package.json" format = "json"

JSON5

{
  $schema: "https://ferrflow.com/schema/ferrflow.json",
  workspace: {
    tagTemplate: "{name}@v{version}",
  },
  package: [
    {
      name: "api",
      path: "packages/api",
      changelog: "packages/api/CHANGELOG.md",
      sharedPaths: ["packages/shared/"],
      versionedFiles: [
        { path: "packages/api/Cargo.toml", format: "toml" },
      ],
    },
    {
      name: "site",
      path: "packages/site",
      changelog: "packages/site/CHANGELOG.md",
      sharedPaths: ["packages/shared/"],
      versionedFiles: [
        { path: "packages/site/package.json", format: "json" },
      ],
    },
  ],
}