Skip to content

Commit trailers and annotations

Everything colophon reads out of a commit message, in one place. A commit can carry meaning in three positions, and colophon reads all three:

  • the subject line, which decides the version bump and the changelog entry
  • the trailer block, the message's last paragraph, which carries the Key: value annotations below
  • a fenced block in the body, for note text too long for one line

Nothing else in a commit is read. A commit that carries none of this is still counted, and still appears in the considered total.

At a glance

Annotation Where What it does
type(scope): subject subject line decides the bump and writes the changelog entry
type!: subject subject line marks the change breaking
BREAKING CHANGE: trailer block marks the change breaking
Release-As: trailer block sets this release's version outright
Release-Note: trailer block contributes one line of prose to the notes
```release-note body contributes longer prose to the notes
Updates: trailer block carries a statement to a forge object when the commit lands

Keys are matched case-insensitively, so Release-As:, release-as: and RELEASE-AS: are the same annotation.

What counts as a trailer

Git treats a line as a trailer only when it sits in the message's last paragraph, and only when every line in that paragraph is a Key: value pair. A Key: value line followed by more prose is ordinary text, and colophon will not read it. The one exception colophon makes, for a forge closing reference, is below.

feat: add the widget

Some explanation of the change.

Release-Note: The widget config key moved to widget.enabled.

A value may continue onto indented lines beneath it, and colophon folds those into the value as git does:

Release-As:
  1.0.0

Every value for a key is read, not just the last. Two Release-Note: lines contribute two notes; two conflicting Release-As: lines are an error rather than a silent tie-break, because keeping one would hide both the conflict and any malformed value that happened to come first.

Closing references are allowed

A commit that ends with a forge closing reference still has its annotations read:

fix(publish): read the manifest at the released commit

Release-Note: publish now reads tag_also at the commit being released.

Closes #13

Closes #13, Fixes cicd#42, Refs !18 and See group/sub/project#7 are tolerated, whether they sit in the trailer block or in a paragraph of their own below it. They contribute nothing themselves; they simply stop disqualifying the annotations above them.

This reads more than git does, deliberately. git interpret-trailers --parse treats a bare Closes #13 as prose, and prose in the last paragraph means no trailer block at all — so git reports no trailers for the message above, and colophon reports the note. Everything git does read, colophon reads identically; what is added is the message whose git form was got wrong by accident, which is otherwise discarded in silence. Writing Closes: #13 with a colon satisfies both tools.

The tolerance is narrow on purpose: a keyword, then one reference token beginning # or !, optionally with a project path. A fence, a sentence, or Closes issue #15 is not tolerated, because the rule exists to stop a fenced Release-As: inside documentation cutting a permanent tag.

An annotation colophon cannot read is reported

Where the block still does not qualify — prose after the trailers, or an annotation inside a fenced example — plan and propose say so:

Warning: abc1234 carries a Release-Note: line outside the trailer block, so it decides nothing

A warning rather than an error: a message may quote an annotation in prose, and refusing would make a repository unreleasable over a commit already on its branch.

The subject line

type(scope)!: subject

type decides the bump:

Type Bump
feat minor
fix patch
perf patch
refactor patch
anything else (chore, docs, test, ci, style, …) none

A type that moves nothing is still considered and still counted; it simply does not decide a version. A commit whose subject is not Conventional Commits at all is treated the same way: counted, deciding nothing, and its trailers are still read.

scope is optional. It appears in the changelog entry and has no effect on the version.

! before the colon marks the change breaking, with the effect below.

Breaking changes

A change is breaking when its subject carries !, or its trailer block carries a BREAKING CHANGE: footer.

feat(api)!: drop the v1 endpoints
feat(api): reshape the endpoints

BREAKING CHANGE: The /v1 path is gone.

At 1.0.0 and above a breaking change is a major bump.

Below 1.0.0 it is a minor bump, not a major one, and the plan says so:

breaking change, held to a minor below 1.0 — promote deliberately with a `Release-As: 1.0.0` trailer

Semantic versioning permits breaking changes in a minor below 1.0, and reaching 1.0.0 is a decision a maintainer makes rather than something a commit footer does to them. Promote with Release-As: 1.0.0 when the project is ready. See How the version is decided.

Release-As:

Sets the version for this release, overriding the computed one.

Value A semantic version. A leading v is accepted and stripped.
Scope Honoured wherever it appears among the commits being considered.
Conflict Two different values in one range is an error, not a tie-break.
Invalid value An error. It does not fall back to the computed version.
Reported as Note: version set to <v> by a Release-As: trailer
git commit --allow-empty -m "chore: promote" -m "Release-As: 1.0.0"

See Set the version explicitly.

Release-Note:

Contributes prose to the release's notes section.

Trailer key release-note
Fence info string release-note
Per commit Any number, in the order they appear in the message.
Ordering Fenced blocks always precede trailers, as a consequence of the last-paragraph rule.
Unterminated block Ignored, rather than read to the end of the message.

Trailer form, for one line:

Release-Note: The widget config key moved to widget.enabled.

Fenced form, for anything longer:

```release-note
Gadgets built before 0.2 need regenerating.
Run `gadget regen` once after upgrading.
```

Where the section appears and what it is headed is set in .colophon.yaml. See Write a release note.

Updates:

Carries a statement to a forge object when the commit lands, rather than when a release is cut. Applied by colophon apply, which a pipeline runs on every push to the target branch. It is how a record outside the repository — an issue, a merge request, a wiki page — is told what became of the work.

Updates: <target> <keyword> [text]

Target names the object. A project is optional everywhere, and omitting it means the repository the commit is in — which is the common case, and the one to reach for first:

Form Means
#123 issue 123 in this repository
!45 merge request 45 in this repository
/wikis/specs/0079-thing a wiki page in this repository
phpboyscout/cicd#123 issue 123 in another project
phpboyscout/cicd!45 merge request 45 in another project
phpboyscout/cicd/wikis/specs/0079-thing a wiki page in another project

Two things about this are easy to get wrong, and both fail late:

  • A project is a full path, not a name. cicd is not a project; the project is phpboyscout/cicd. A bare name parses perfectly well and then resolves to a project of that name at the root namespace, so the mistake surfaces as 404 Not Found when the trailer is applied — after the merge, when the commit can no longer be changed.
  • The wiki form for this repository needs its leading slash. /wikis/specs/0079-thing is a page in this repository; wikis/specs/0079-thing names nothing and is refused, because without the slash there is no separator to split a project from a path.

Keyword is one of a small closed vocabulary. A word colophon does not know is refused and reported, never guessed at:

Keyword On an issue or merge request On a wiki page
shipped comments "Shipped." sets status: IMPLEMENTED
implemented comments "Implemented." sets status: IMPLEMENTED

Text after the keyword is optional. It is added to the comment on a tracker object, and ignored on a wiki page, where there is nowhere to put it; the run says so rather than dropping it silently.

fix(publish): read the manifest at the released commit

Updates: #13 shipped Fixed in v0.5.0.
Updates: /wikis/specs/0079-pipeline-churn implemented

The same commit moving a spec in another project names it in full:

Updates: phpboyscout/cicd/wikis/specs/0079-pipeline-churn implemented

Each comment carries a marker naming the commit that asserted it, so a pipeline retried by a person or a runner posts nothing twice. A later commit saying the same thing about the same object is a second comment, because it is a second assertion.

A target the forge cannot carry — a wiki on a forge without one, an issue comment on Bitbucket — is reported and skipped, and the rest still apply. A skipped target does not fail the run, because no change to the repository could clear it. A target the forge refuses does: the rest are still carried, and the failure is reported to the caller. See colophon apply for what each forge can do and what the run exits.

Colophon never decides for itself that work is finished. A record is updated because a commit said so, exactly as Release-As: states a version the tool cannot work out. See spec 0012.

Where each one is read

Annotation plan propose publish release
subject line and ! yes yes — —
BREAKING CHANGE: yes yes — —
Release-As: yes yes — —
Release-Note: and its fence yes yes — when notes are generated
Updates: — — — —

apply reads Updates: and nothing else; the release verbs read everything else and not it.

publish reads the changelog file at the merged commit rather than the commits themselves, which is why the annotations do not appear in its column: by then they have already been rendered into the file propose wrote. See Tagging what actually landed.