Release manifest (.colophon.yaml)¶
An optional file committed to the release branch. It holds a release back, shapes the release-notes section, and names directories that are tagged alongside the root. It carries nothing else.
Absent, empty, or comments-only, it means the same as every field at its default.
Schema¶
| Field | Type | Default | Meaning |
|---|---|---|---|
hold |
boolean | false |
Compute and display the release, but do not let publish act on it. See what counts as a boolean. |
reason |
string | empty | Shown wherever the hold surfaces. |
release_notes.placement |
string | prepend |
prepend or append. Where the notes section sits. |
release_notes.heading |
string | empty | Section heading. Empty means Notes. |
release_notes.standing.prefix |
string | empty | A file whose text opens every release body. Repository-relative, read at the released commit. See standing release text. |
release_notes.standing.suffix |
string | empty | A file whose text closes every release body. Repository-relative, read at the released commit. See standing release text. |
tag_also |
list of strings | empty | Directories that receive their own tag, <dir>/<root tag>, at the same commit as the root tag. |
root |
string | empty | none switches the root off: no root version, tag, changelog or release, and only components release. See a repository with no root. |
forge |
string | empty | gitlab, github or gitea. Names the forge when the remote's host does not: a self-hosted GitLab, a GitHub Enterprise Server or a Gitea instance. |
components |
mapping | empty | Directories with a version of their own, or the root's, in document order. Each carries a required version (shared or own) and optional tag, hold, reason, release and changelog. See below. |
versioning.scheme |
string | semver |
semver or ordinal. How this repository's versions are shaped. See versioning schemes. |
versioning.prefix |
string | empty | The ordinal scheme's prefix, e.g. n9.0.1. |
versioning.prefix_file |
string | empty | A file in the repository holding the prefix instead. Exactly one of prefix and prefix_file. |
versioning.tag |
string | empty | The root's tag template, e.g. rtb-error-v{version}. Empty means vX.Y.Z. See a root tag of your own. |
versioning.files |
list | empty | Files a release writes its version into, each - <adapter>: <path>. cargo is the one adapter. See version files. |
assets |
list of strings | empty | The files a release carries, for a project whose artefacts no build tool describes. Empty means colophon reads goreleaser's manifest instead. See naming your own assets. |
announce.<adapter>.webhook |
string | empty | An environment reference, $NAME, never a value. A literal is refused. |
announce.<adapter>.when |
string | any |
any, patch, minor or major. The smallest release this adapter announces. Every adapter takes it. |
announce.file.* |
mapping | empty | Appends the release to a structured file in another repository and commits it. See announcing into a file. |
An unknown field is an error, not a warning. A typo therefore fails loudly rather than silently holding nothing.
Example¶
hold: true
reason: waiting on the downstream migration
release_notes:
placement: prepend
heading: Upgrading
tag_also:
- lint
forge: gitea
components:
tools/capabilitygen:
version: own
tag: "{name}-v{version}"
hold: true
reason: waiting on the SDK
sdk:
version: shared
Standing release text¶
A release body is not only what changed. For a project publishing artefacts it is also what the artefacts are — which licence variant to pick, what the profiles contain, how to verify a signature. That part is constant, correct on every release, and belongs to the release rather than to the changelog.
Both are optional and either stands alone. The prefix opens the release body and the suffix closes it; there is no placement key, because the key's name is the placement. That is deliberate — the case that motivated the feature wants both at once, a licence note above the news and verification instructions below it, and a single file with a placement value could only ever have one.
release_notes.placement and release_notes.heading are unaffected. They place
and title the notes section within the body; the standing blocks wrap the
whole of it.
Three things are worth knowing:
- The files are read at the released commit, like
CHANGELOG.mdand this manifest, so a release carries the text that was true when it was cut rather than whatever the default branch says now. - They reach the release body only, not
CHANGELOG.md. Putting them in the changelog would repeat the same text once per release, and correcting a figure would mean editing history or leaving old sections wrong. - A named file that is not there does not stop the release. Colophon warns
and releases without it.
planandproposewarn too, which is the point at which it is still free to fix.
A component inherits the root's standing block and may override either key on its own, so a component can replace the prefix and keep the root's suffix:
release_notes:
standing:
prefix: docs/preamble.md
suffix: docs/verify.md
components:
cli:
version: own
standing:
prefix: cli/docs/preamble.md # suffix still comes from the root
Nothing a commit writes can change standing text. Release-Note: is for what
changed in this release; this is what is true of every release, and it is a
file in the repository so that a review sees it change.
Notes on the fields¶
tag_also names directories to tag alongside the root. For an entry lint
and a release v0.6.0, publish cuts lint/v0.6.0 at the same commit, right
after v0.6.0, in both its modes. That is the tag Go requires for a nested
module in lint/, and it is what lets a consumer resolve
…/[email protected] and Renovate bump it. colophon reads nothing inside the
directory: it is not a Go tool, and whether the directory holds a module, a
crate or nothing is not its business. A wrong entry produces a tag nothing
consumes.
The manifest that governs a release is the one at the commit being
released, not the checkout publish runs from. publish runs on every push
to the target branch and finds the last merged release, so an entry added after
that release was cut does not reach it: the re-run reports the release as
already present and, for each directory the checkout names that the released
commit does not, says not cut — v0.5.0 predates the tag_also entry. The entry
takes effect from the next release.
An entry must be a relative path git would accept as part of a tag name; a
leading /, a . or .. segment, a component starting with ., whitespace
and the characters git refuses in a reference are all rejected when the file
is read, before anything is tagged. Each nested tag is reported by name in
publish's output, created or already present, and a nested tag found on a
different commit stops the run the way the root tag would.
A tag with a directory prefix is not a root release: plan and propose
ignore it when deciding the version, and release --tag lint/v0.6.0 is
refused, naming v0.6.0 as the tag to release. Pushing one starts a tag
pipeline wherever the workflow admits tags, and tag protection patterns of
v* do not cover it.
reason is optional to the parser and not optional in practice. A hold with
no stated reason is one nobody else can lift, because they cannot tell whether
the thing it was waiting for has happened.
placement defaults to prepend because these are most often upgrade or
migration notes, and a warning after the feature list is a warning read too
late.
What is deliberately absent¶
There are no version overrides here. An earlier design carried them, an exact
version and a bump floor among them, and they moved to a
Release-As: trailer because the mechanism should
match the lifetime of the thing it controls. A hold is a state a project is in
and stays in until lifted, which suits a committed file. A version override
applies to exactly one release and should expire with it, which a file does not
do.
See also¶
forge names the forge for a remote whose host colophon does not know.
gitlab.com, github.com, codeberg.org and bitbucket.org are recognised by
host; any other host is refused until the repository says what it is. The name
picks the go/forge adapter, the token variable (GITLAB_TOKEN, GITHUB_TOKEN
or GITEA_TOKEN) and the shape of the links in the changelog; the host comes
from the remote as before. codeberg and bitbucket are refused: Codeberg is
codeberg.org, which is detected, and the Bitbucket adapter talks only to
api.bitbucket.org. A name that disagrees with a known host, forge: gitea on
a gitlab.com remote, is refused rather than honoured. The --forge flag on
propose, publish and release says the same thing for one run, for a job
that cannot rely on the file; when both are given they must agree.
components declares the directories a repository versions, in the
order the file states them. version is required: shared means the
directory takes the root's version and is tagged alongside it, which is what
tag_also does with less to say; own means it is decided, changelogged
and tagged on its own cadence, from the commits since its last tag that touch
it. The same directory cannot appear under both components and tag_also,
and a component inside another component is refused.
tag is the shape of the component's tags, a template over {dir}, {name}
(the last path element) and {version} (no v). The default is
{dir}/v{version}, the shape Go requires for a nested module; a Rust or Node
convention is {name}-v{version} or {name}@{version}. A template must
end with {version}, must render to a name git accepts, and must not render
to something colophon would read as the root's own version, so v{version}
is refused; two components may not render alike. hold and reason hold
one component as the top-level keys hold the root.
What an own component gets: its own block in plan, its own
section and <dir>/CHANGELOG.md in the request propose
opens, decided from the commits that touch its directory since its own last
tag. When that request merges, publish cuts the
component's tag at the merged commit and creates its release object from the
same section, and release --tag does the same for a
component's tag cut by hand.
A component's release and changelog¶
version says how a component's version is decided. Whether its tag carries a
release object and whether it keeps <dir>/CHANGELOG.md are separate
switches, so any shape a workspace wants can be written down:
components:
crates/rtb-docs:
version: shared
tag: "rtb-docs-v{version}"
release: true # its tag gets a release object
changelog: true # it keeps crates/rtb-docs/CHANGELOG.md
release default |
changelog default |
|
|---|---|---|
own |
true |
true, and it cannot be turned off |
shared |
false |
false |
The defaults are what components did before the switches existed, so a manifest that sets neither means what it always meant.
changelog: trueon asharedcomponent writes a section at the root's version, under the component's tag, holding the commits since its last tag that touched the directory. A release that touched nothing there still gets a section, saying the version moved with the root, so the file never skips a version its tags carry.release: trueon asharedcomponent creates a release object on its tag aspublishcuts it, or asrelease --tagruns on its tag pipeline. Its body is its own section when it keeps a changelog, and the root's section otherwise. Like any release a run creates, it is announced.release: falseon anowncomponent still tags it and writes its changelog; only the release object is withheld.changelog: falseon anowncomponent is refused. Its changelog heading is where its version is recorded betweenproposeandpublish; without it, it would never be tagged.
Both take a boolean, read the way hold is.
A repository with no root¶
A workspace of libraries may have nothing at its root to release. root: none
says so:
root: none
components:
crates/rtb-error:
version: own
tag: "rtb-error-v{version}"
crates/rtb-config:
version: own
tag: "rtb-config-v{version}"
The root then has no version, no tag, no changelog and no release, and only the components release. What the root's other settings mean:
- A commit outside every component releases nothing.
planlists such commits, so a directory nobody declared is visible. - The top-level
holdholds every component, with nothing else to hold. A component's ownholdstill holds just that one. - The top-level standing block is still what components inherit.
release --tag v1.2.3is refused: a tag of the root's shape belongs to no release here.
Refused when the manifest is read, because each needs a root:
- no components at all, which would release nothing;
- a
sharedcomponent, whose version is the root's; tag_also, which tags at the root's version;versioning.tag, which shapes the root's tag.
Announcing a release¶
A release announces itself, through the adapters this file names, from the
verb that created its release object — publish on the
default branch, or release on the tag pipeline — and only
for a release that run created. A re-run that finds the release already there
reports it and tells nobody; --no-announce on either verb withholds it and
says so. colophon announce --tag announces again by hand.
What is announced is read from the release object, so the words it quotes and the words a reader finds on arrival are the same.
Announcing is opt-in, and the operator may supply the settings¶
Nothing is announced unless this file names the adapter. An announcement is an unusual side effect of a release — a blog post most of all — and it is the project's to ask for, in its own committed words, never something inherited.
What the operator may do is supply an adapter's settings in colophon's own configuration, so a feed every project in an estate shares is written once. The project then opts in with a line:
A block with settings of its own lays them over the operator's, field by field. An adapter the operator configures and this file does not name does nothing. An adapter named here with nothing to inherit and nothing of its own is refused when the manifest is resolved, naming the setting it lacks.
The size is a bump, not a version shape¶
when takes any, patch, minor or major, and it is the size of the
release rather than the shape of its version. That matters for a project whose
versions are not semver: an ordinal release has no patch and no major, so
when: any is the only sentence that means anything there — and it means it.
Announcing happens at least once¶
This is the only thing colophon does that is not idempotent, and it does not pretend otherwise. A release that exists is reported rather than created twice; a tag that exists is left alone. An announcement is not like that. There is no idempotency key for a webhook, so a retried job announces again.
An adapter that can tell reports unchanged instead — a blog post that is
already there, a message a service recognises. A webhook cannot, and never
claims to.
A webhook is a credential¶
.colophon.yaml is committed, reviewed and mirrored. So the manifest names
the environment variable and the CI job supplies the value:
webhook: $DISCORD_RELEASE_WEBHOOK # read from the environment
webhook: https://discord.com/api/... # refused, when the file is parsed
The refusal happens at parse time rather than at delivery, because the failure it prevents is not a broken announcement — it is a secret in git history, and that is not undone by fixing the file afterwards.
A failed announcement never fails the pipeline¶
Each adapter reports its own outcome and the verb exits zero. By the time it runs the release is public, and a red pipeline over a chat message is worse signal than a yellow one. One adapter failing does not stop the others.
A zero exit is also a green job, though, so a feed that missed a release goes
unnoticed. To make it show, pass --announce-failure-exit to publish,
release or announce with a code the job allows to fail:
colophon-publish:
script:
- colophon publish --announce-failure-exit 3
allow_failure:
exit_codes: [3]
The job goes yellow when any announcement reported failed, counted across
every release the run made, and stays green when each one was announced,
skipped or unchanged. A real failure still exits 1 and goes red. Codes 1,
2 and anything above 125 are refused before the run does anything. A script
that prints something after the command should capture the status
(colophon publish ... || status=$?) rather than let set -e end it there.
There is no environment variable for it: every COLOPHON_ANNOUNCE_* variable
is read as part of the operator's announce block.
Naming your own assets¶
Colophon normally learns what a release should carry from goreleaser: it
reads dist/artifacts.json and keeps the artefacts whose type belongs on a
release — the set goreleaser itself uploads (archives, packages, source
archives, SBOMs, checksums, signatures and certificates) and goreleaser Pro's
installers (DMG, MSI). The raw build binaries and goreleaser's own metadata are
left off. A Go project in this estate needs to say nothing.
A project that builds its artefacts some other way names them instead:
Each name is joined to --asset-base, and every one is checked before
anything is published — a name that does not resolve stops the release rather
than publishing a link that 404s. That check is the same one goreleaser-built
assets get, and it matters more here: colophon is then the only thing verifying
that whatever uploaded your artefacts actually did.
Names are published in the order you write them.
What is refused¶
- A path separator. Name the file as it is published —
engine.wasm, notdist/engine.wasm. The name is escaped into a URL, so a separator would point at nothing. - The same name twice, and an empty entry.
assetstogether with--assets. They are alternatives: one names the files, the other points at a goreleaser build. Having both would leave it unclear which source a missing asset should have come from.
What you take on¶
Goreleaser's list is filtered by artefact type, so colophon will not publish its intermediate build output by accident. A named list has no such filter and needs none — every entry is there because somebody wrote it down — but the judgement about what belongs on a release becomes yours.
Versioning schemes¶
Every repository in the estate releases semver, and a manifest that says
nothing gets it. One does not: ffmpeg-wasi is a build of FFmpeg, and its
releases name the upstream release they are built from and which of our builds
of it this is — n9.0.1-6.
The prefix names something outside this repository and colophon never computes it. The ordinal counts our builds of that prefix, increments by one for any release, and restarts at 1 when the prefix moves:
The ordinal says nothing but "which build"¶
It does not encode a break, a feature or a fix. A feat!: increments it by one
exactly as a fix: does, and a breaking change is described in the release
notes, where a person reads it. The commit types still decide whether there
is a release at all — a lone chore(deps) still cuts nothing — and they still
sort the changelog. What they cannot do under this scheme is change the number.
Taking the prefix from a file¶
Where the repository already has a source of truth for the prefix, name it rather than copying the value:
Colophon reads the first line with anything on it once # comments and
whitespace are stripped — the rule a shell script reading the same file would
apply. Use this whenever something else in the repository already reads that
file, so there is one value rather than two that must agree.
Releasing one¶
Nothing about the verbs changes. propose writes a changelog whose heading is
the tag — ## [n9.0.1-6] — publish reads that heading back and cuts exactly
that tag, and release --tag n9.0.1-6 creates the release object for it.
Two consequences of the version not being semver, both deliberate:
- The tag carries no
v. Under this scheme the version is the tag. - A tag naming no version is refused a moment later than it used to be.
Whether a name is a version depends on the scheme, and the scheme is in the
manifest at the tagged commit, so
release --tagreads the manifest before it can say. The refusal is the same; it costs one read that a mistyped tag no longer avoids.
What is refused¶
- A prefix that moves backwards. If the newest tag is
n9.0.1-5and the manifest saysn8.1.2, the run stops: the tag it would cut orders below one already cut, and which of the two is right is a judgement about what the repository is building. prefixandprefix_filetogether, and neither. They are alternatives and one is required.- A prefix beside
scheme: semver, which means nothing and is therefore a typo. componentsbesidescheme: ordinal. A component's tag template and changelog heading are read as semver, so the combination would quietly do the wrong thing. Refused rather than half-supported.
A root tag of your own¶
A root release is tagged vX.Y.Z unless the manifest names another shape. A
crate that has always been tagged rtb-error-v0.6.3 keeps that shape:
{version} is the only placeholder, because the root has no directory for
{dir} or {name} to name, and the template must end with it. Everything
follows the template:
- History reads the root's releases from tags of this shape only. The
existing
rtb-error-v0.6.3is the last release on the first run, and avtag the repository carries from before the template is not. proposewrites the changelog heading as the rendered tag,## [rtb-error-v0.6.4], andpublishcuts exactly that.release --tag rtb-error-v0.6.4is a root tag, likev0.6.4without a template.
What a maintainer types does not change. A Release-As:
trailer takes a version, 0.7.0 or v0.7.0, never the rendered tag.
What is refused¶
- A template that does not end with
{version}, names another placeholder, contains a/(which marks a directory's tag) or renders to a name git refuses. tagbesidescheme: ordinal, whose version is already its tag.tagbesidetag_also, whose<dir>/<root tag>is Go's nested-module shape and assumes avroot.- A component whose tags the root would read as its own: one whose rendered tag is the root's prefix followed by a version.
Version files¶
Go takes its version from the tag. Most other languages read it from a file,
and a release whose file disagrees with its tag publishes the wrong version.
versioning.files names the files, and propose writes the new version into
them in the same release commit as the changelog:
Each entry is one adapter and the file it starts from. Paths are repository-relative.
cargo¶
Name the root Cargo.toml. The adapter finds the rest itself:
- The version. That is
[workspace.package] versionin a workspace, otherwise[package] version. - Every workspace member. These come from
members, with globs expanded andexcludehonoured. A member must inherit the workspace's version (version.workspace = true) unless it sits in anowncomponent, where it takes that component's version. - Requirements on a member. These are the
versionof any dependency whosepathpoints at a member, in any dependency table or form, including[workspace.dependencies]. The path decides, never the name: a registry requirement that happens to carry the same number is left alone. Cargo.lockbeside the manifest, when there is one. It updates each member's entry and each"<member> <version>"that names one. A registry crate of the same name has asourceline and is left alone.
It edits text and changes only version strings. Comments, ordering and spacing stay as you wrote them, because it never re-serialises a file.
A requirement must pin exactly to be moved. Bare 0.9.0, ^0.9.0 and
=0.9.0 move to the new version, keeping their operator. A range, ~, >= or
a partial 0.9 stops the proposal and names the dependency, because whether
the new version should satisfy it is your judgement, not colophon's.
What it cannot read, it refuses. That includes a member with a version of its own on the root's line, an inherited version with nothing to inherit, or a version written with escapes. Colophon proposes nothing rather than a release whose files disagree with its tag.
What is refused when the manifest is read¶
- An adapter colophon does not have.
- An entry naming two adapters, or none.
- A path outside the repository, or the same file twice.
filesbesidescheme: ordinal, whose version is its tag.
What counts as a boolean¶
hold, at the root and inside a component, takes YAML 1.2's booleans:
Go's spellings are accepted too — 1, 0, t, f and their upper-case
forms — because they have been accepted since the field existed and somebody
will write one. They are read only after the parser has said the value is not
a boolean, so they can never change what a real YAML boolean means.
yes, no, on and off are refused, which surprises people, so it is
worth saying why. YAML 1.2 removed them: they are strings, and the parser
reading your manifest types them that way. Accepting them would mean colophon
reading as true a value its own parser calls the string yes. A refusal is
also the safe direction here — it fails the run rather than letting a release
out of a repository that meant to hold one.
A value that is not a boolean at all, including a bare hold: with nothing
after it, is refused the same way.
Your manifest is read as YAML 1.2¶
Colophon states the language rather than letting the file choose it, so a
%YAML 1.1 directive at the top of .colophon.yaml is refused rather than
obeyed. Under 1.1 hold: yes would be a boolean and would stop a release,
which is exactly the reading the rule above refuses — reachable, without that,
by a route the rule never sees.
Two consequences worth knowing:
- A quoted value is the text you quoted.
tag_also: ["null"]names a directory callednull; an unquotednullstill names no directories. Names, paths and headings are read as written, so an unquoted2024is a directory called2024rather than an integer. - A field written twice is refused, rather than one of the two winning
silently. A manifest with two
holdkeys does not say whether to stop a release, and guessing is the wrong answer for a control that stops one.
Announcing into a file¶
The file adapter appends each release to a list in a YAML file in another
repository, and commits it. It is how a release reaches something that renders
from data — a changelog page, a feed, a status board — without anybody doing
anything.
announce:
file:
repo: phpboyscout/blog # owner/name, a full path
token: $BLOG_TOKEN # an environment reference, never a value
branch: main
strip_prefix: phpboyscout/ # colophon knows this repo as phpboyscout/go/config
path: data/releases/{path_dashed}.yaml
format: yaml # optional; the extension decides by default
list: releases # the sequence appended to
key: url # the field that says "already there"
entry: # appended, in this order
name: "{name}"
tag: "{tag}"
date: "{released_at}"
url: "{url}"
header: # written once, when the file is created
project: "{path}"
name: "{project}"
url: "{project_url}"
message: "chore(releases): {path} {tag}"
What a template may name¶
| Placeholder | Is |
|---|---|
{project} |
the repository's own name, config |
{path} |
its path, with strip_prefix removed: go/config |
{path_dashed} |
the same with / replaced by -: go-config |
{tag} |
the tag cut, v0.6.1 |
{version} |
the version it carries, 0.6.1 |
{name} |
the release's own header on the forge, usually the tag |
{url} |
the release page |
{project_url} |
the repository page |
{released_at} |
when the forge published it, RFC 3339 |
{bump} |
patch, minor or major |
strip_prefix exists because colophon knows a repository by its full forge
path. A record keyed by position within one group wants go/config, and the
group is not something colophon can infer, so you name it.
YAML or JSON¶
Both are supported. format is optional: a .yaml, .yml or .json path
decides for itself, and an extension the adapter does not read is refused rather
than guessed at. Name format explicitly when the path has no extension, or
when it has one that does not match the contents.
Getting this wrong used to be silent, which is why it is inferred rather than
defaulted: JSON is a subset of YAML, so a .json file read as YAML parses
happily and is written back out as YAML on the first release.
A JSON file is edited textually — the adapter finds the array, inserts the entry and leaves every other byte alone. It does not decode and re-encode, which would reorder the fields of every entry already in the file, because Go does not preserve a JSON object's key order.
It only ever appends¶
Before writing, the adapter reads the file and looks for an entry whose key
field already equals this release's. If it is there, nothing is written and
the run reports unchanged. That is the whole of its idempotency, and it is
why the key lives in the file rather than in state kept somewhere else: a
retried pipeline re-reads the same file and sees its own previous write.
Existing entries are never reordered, reformatted or corrected. The file is a record of what shipped, a person may have edited an entry, and a release withdrawn upstream stays — so the adapter only adds.
{released_at} is the forge's own publish time rather than the pipeline's
clock, so a job that ran late or was retried still sorts where it belongs. Note
that on Gitea and Bitbucket the forge reports the tag's date; that is what those
forges sort their own listings by, so the file agrees with the page a reader
clicks through to.
Committing, or proposing¶
mode: commit (the default) commits to branch and pushes. For a protected
branch, mode: merge_request pushes a branch and opens a request instead:
The branch name is derived from the project and tag, so two releases landing together never collide and a re-run finds its own request rather than opening a second.
merge: when_ready asks the forge to merge when its checks pass. It is not
"merge now": a forge that cannot wait for checks — Bitbucket — is reported as
not honoured and the request is left open, rather than being given a plain merge
instead. The two differ exactly when the checks would have failed, which is the
case the setting exists for.
A scheduled result means the forge accepted the request and nothing more. It
does not promise the merge will happen, so the report says the merge was
requested; what became of it is a question for the forge.
The token¶
token names an environment variable and is refused if a value is written into
the manifest, exactly as a webhook is. It needs write_repository on the target
and nothing else.
On GitLab CI it can be the job's own token, $CI_JOB_TOKEN, so no personal
token is needed at all. colophon recognises it and presents it to git as
gitlab-ci-token, the only username GitLab accepts a job token under. The
target project must allow it: under CI/CD → Job token permissions, turn on
Git push requests and cross-project Git push requests, and allowlist the
project or group that releases. mode: merge_request still needs an ordinary
token, because a job token cannot open a merge request.