Skip to content

Configuration

An optional .draftsman.yml at the repo root overrides the built-in defaults. Every field is optional — an absent file, or a file that sets nothing, behaves identically to the built-in defaults below. Point at a different path with --config (see CLI reference).

A full, commented example with every field set is at .draftsman.example.yml in the repo root. A JSON Schema is also published — point your editor at it for validation and autocomplete:

# yaml-language-server: $schema=https://brpaz.github.io/draftsman/schema/config.schema.json

mode

mode: single # default
  • single — one Draft Release, one repo-wide version. packages, if set, only sections entries within that single release; it does not split versions or tags.
  • multi — one Draft Release per Package, each with an independent version and tag.

See ADR-0004 for why this is an explicit choice rather than an implicit "everything is one Package" default. Switching single → multi later doesn't cleanly re-partition existing tags into per-package sequences — decide this early.

categories

categories:
  - type: feat
    section: Features
  - type: fix
    section: Bug Fixes
  - type: perf
    section: Performance
  - type: docs
    section: Documentation

Maps a Conventional Commit type to a changelog section name. Order in the list is the display order of sections in the rendered output. Commit types with no matching entry fall into an "Other" section.

Setting categories at all is a full replace, not a merge with the built-in default — list every type/section pair you want, in the order you want. This is deliberate: a partial merge can't express reordering unambiguously when only some types are overridden.

Matching by scope too

An optional scope field narrows a category to a specific Conventional Commit scope, e.g. fix(security): ... or chore(deps): .... A commit is matched against categories in order, first match wins — so a scope-specific rule only takes precedence over a broader type-only rule for the same type when it's listed before it:

categories:
  - type: fix
    scope: security
    section: 🔐 Security
  - type: fix
    section: 🐛 Bug Fixes
  - type: chore
    scope: deps
    section: 🧩 Dependency Updates
  - type: chore
    section: ⚠️ Maintenance

With this config, fix(security): patch auth bypass lands in Security; any other fix(...) (or fix: with no scope) falls through to Bug Fixes. A scope of "" (the default when omitted) matches any scope for that type — it's how the plain type-only rules above still catch everything the scope-specific rules don't. Listing the type-only fix rule first would make the security-scoped rule unreachable — ordering, not specificity, decides precedence.

Built-in default:

categories:
  - type: feat
    section: Features
  - type: fix
    section: Bug Fixes

packages

packages:
  - path: packages/api
    name: API
  - path: packages/web
    name: Web

Maps a path prefix to a monorepo Package name. A commit is attributed to every Package whose path prefixes one of the commit's changed files — a commit touching files under two or more configured prefixes produces entries in both.

In single mode, this only sections entries within the one Draft Release. In multi mode, each Package gets its own Draft Release, version, and tag — see mode above.

Setting packages at all is a full replace of the (empty) default, same reasoning as categories.

skip-changelog-trailer

skip-changelog-trailer: Skip-Changelog # default

A commit whose message footer contains this trailer key is excluded from the changelog entirely — useful for chore:/internal commits that shouldn't appear in release notes. Example commit footer:

fix: correct off-by-one in pagination

Skip-Changelog: true

[skip changelog] tag

For a single-line commit, a footer trailer needs a multi-line message. A [skip changelog] tag anywhere in the commit message — subject or body — does the same thing, [skip ci]-style:

git commit -m "chore: bump lockfile [skip changelog]"

Case-insensitive, and always recognized — unlike skip-changelog-trailer, this literal tag isn't configurable.

tag-format

tag-format: "v{{version}}" # default

A template string used both to locate the previous release tag (range-finding) and to render the tag for a new release. {{version}} is replaced with the computed SemVer string.

In multi mode, {{package}} is also available and typically required to keep each Package's tags distinct:

tag-format: "{{package}}/v{{version}}"

In single mode, {{package}} is accepted in the template but always resolves to an empty string — there's only one Package's worth of tags to render.

template

Overrides the built-in Go text/template used to render the changelog body — see that page for the templating syntax itself ({{if}}, {{range}}, {{with}}, pipelines, etc.); the table below only covers the data draftsman feeds into it. The built-in default (shown below) is Markdown, with an {{if .SuggestedVersion}} heading guard that keeps single-package output identical to before Packages existed:

{{if .SuggestedVersion}}# {{.SuggestedVersion}}

{{end}}{{range .Packages}}{{if .Name}}# {{.Name}}{{if .SuggestedVersion}} ({{.SuggestedVersion}}){{end}}
{{end}}{{range .Sections}}## {{.Name}}
{{range .Entries}}- {{if .Breaking}}**💥 BREAKING:** {{end}}{{.Description}}{{if .PR}} ({{if .PR.Link}}[#{{.PR.Number}}]({{.PR.Link}}){{else}}#{{.PR.Number}}{{end}}){{end}} by {{if .AuthorRef}}[@{{.AuthorRef.Login}}]({{.AuthorRef.ProfileURL}}){{else}}{{.Author}}{{end}} ({{if .CommitURL}}[{{.ShortSHA}}]({{.CommitURL}}){{else}}{{.ShortSHA}}{{end}})
{{end}}
{{end}}{{if .Name}}{{if .CompareURL}}
**Full Changelog**: {{.CompareURL}}
{{end}}{{else}}{{if $.CompareURL}}
**Full Changelog**: {{$.CompareURL}}
{{end}}{{end}}
{{end}}{{end}}

$ is Go's text/template root-data reference — inside the {{range .Packages}} loop, . is the current PackagePlan, so the single-mode branch ({{if .Name}} false, the implicit unnamed package) reaches back to the top-level Plan.CompareURL via $.CompareURL instead.

A separate footer attribution line is appended after this template runs (or not — see footer below); it isn't part of the template itself.

Available placeholders

The root template value is a Plan. {{.Packages}}, {{.Sections}}, and {{.Entries}} are slices — range over them to reach the fields below.

Placeholder Type Meaning
{{.SuggestedVersion}} string Computed next version (single mode). Empty in multi mode — use each Package's own .SuggestedVersion instead.
{{.PreviousVersion}} string The version the range was computed from (single mode). Empty if no prior matching tag was found.
{{.PreviousTag}} / {{.SuggestedTag}} string PreviousVersion/SuggestedVersion with tag-format applied — the actual git tag name, not bare SemVer. Empty wherever the corresponding Version field is.
{{.CompareURL}} string Link to a PreviousTag..SuggestedTag diff on the backend's web UI (single mode). Empty when either tag is empty, or no Backend was configured — guard with {{if .CompareURL}}.
{{.Packages}} []PackagePlan Multi mode only — empty in single mode.
{{.Packages}}[].Name string Package display name, from packages[].name in config.
{{.Packages}}[].SuggestedVersion string Computed next version for this Package.
{{.Packages}}[].PreviousVersion string The version this Package's range was computed from.
{{.Packages}}[].PreviousTag / {{.Packages}}[].SuggestedTag string Same as the top-level PreviousTag/SuggestedTag, but for this Package.
{{.Packages}}[].CompareURL string Same as the top-level CompareURL, but for this Package.
{{.Packages}}[].Sections []Section This Package's changelog sections.
{{.Sections}} []Section Single mode: top-level sections. Also reachable per-Package via {{.Packages}}[].Sections in multi mode.
{{.Sections}}[].Name string Section heading, from categories[].section in config (or the built-in "Other" bucket).
{{.Sections}}[].Entries []Entry Changelog entries in this section.
{{.Entries}}[].Description string The commit's Conventional Commit subject line.
{{.Entries}}[].Type string The commit's Conventional Commit type, e.g. feat, fix.
{{.Entries}}[].Scope string The commit's Conventional Commit scope, if any (empty string otherwise).
{{.Entries}}[].Breaking bool Whether the commit carries a breaking-change marker (! after type/scope, or a BREAKING CHANGE: footer). Orthogonal to Type/its Section — a feat!: commit still categorizes as a Feature, Breaking just flags it too (see the built-in default template's {{if .Breaking}}**💥 BREAKING:** {{end}} prefix).
{{.Entries}}[].SHA string Full commit SHA.
{{.Entries}}[].ShortSHA string SHA truncated to git's conventional 7-character abbreviation.
{{.Entries}}[].Author string The commit's plain git author name (git log's %an) — always populated, independent of AuthorRef.
{{.Entries}}[].AuthorRef *AuthorReference The author's linked backend account, resolved via a live API call. nil when no Backend is configured, the backend doesn't support the lookup, or the commit's author has no linked account — guard with {{if .AuthorRef}} and fall back to Author (see the built-in default template's pattern). Currently only resolved on GitHub.
{{.Entries}}[].AuthorRef.Login string The linked account's username.
{{.Entries}}[].AuthorRef.ProfileURL string The linked account's profile URL.
{{.Entries}}[].PR *PRReference nil when no PR was resolved — guard with {{if .PR}}.
{{.Entries}}[].PR.Number int Resolved PR number.
{{.Entries}}[].PR.Link string Resolved PR URL. Empty when the number came from GitHub's (#N) squash-merge title extraction, which carries no URL — guard with {{if .PR.Link}} before linking it (see the built-in default template's {{if .PR.Link}}...{{else}}#{{.PR.Number}}{{end}} pattern). Populated for Gitea/Forgejo's Reviewed-on: trailer extraction and GitHub/GitLab's API fallback.
{{.Entries}}[].CommitURL string Link to the commit on the backend's web UI. Empty when no --backend/--repo was configured (e.g. preview without those flags) — guard with {{if .CommitURL}} (see the built-in default template's pattern).
footer: true # default

Whether draftsman appends its own attribution line — *Release notes generated by [draftsman](https://brpaz.github.io/draftsman/).* — after every rendered changelog body (the top-level body and, in multi mode, each Package's own isolated body). Set footer: false to omit it. This is independent of template: it's appended after the template runs, not part of it, so it applies (or doesn't) the same way regardless of whether template is customized.

Full example

mode: multi

categories:
  - type: feat
    section: Features
  - type: fix
    section: Bug Fixes
  - type: perf
    section: Performance
  - type: docs
    section: Documentation

packages:
  - path: packages/api
    name: API
  - path: packages/web
    name: Web

skip-changelog-trailer: Skip-Changelog
tag-format: "{{package}}/v{{version}}"
footer: true