colophon¶
The jobs:
| job | does | writes |
|---|---|---|
colophon-publish |
a Release MR merged: resolve what landed, tag it, create the release, announce it through the adapters .colophon.yaml names |
tag, release object, announcements |
colophon-propose-next |
re-cut the release branch, open or update the Release MR for the next release | branch, merge request |
colophon-apply |
on every push to the target branch, carry each commit's Updates: trailers to the issue, merge request or wiki page it names |
comments, wiki frontmatter |
colophon-release |
on the TAG pipeline, create the release carrying links to what the build produced | release object with assets |
colophon-release-check |
on the RELEASE MR's pipeline, check the MR carries only the files colophon writes, and report the notes found for each tag the title names; the merge gate's pipeline, so the component jobs can stand aside there | nothing |
A wrapper over phpboyscout/colophon,
the release orchestrator this estate owns and can fix. Sibling of
releaser-pleaser, which it is intended to replace —
see spec 0077.
publish runs before propose, and that is a correctness constraint
After a Release MR merges, the release commit is on the target branch but
the tag does not exist yet. In that window colophon computes the same
release again. Measured on colophon itself at a817445:
$ colophon plan # tag present
No release due — 0.1.0, 0 commits considered
$ colophon plan # same commit, tag removed
0.0.0 → 0.1.0 (minor), decided by 30 commits
A pipeline that proposed first would re-open a Release MR for the version about to be tagged; publish would then tag it, propose would find nothing due, and that merge request would sit open forever proposing a release that has already happened.
The order comes from needs: on the propose job — never on the publish
job, which must stay free of needs: so it falls in behind the consumer's
lint, test and security gates by stage ordering
(spec 0053).
Token requirement
$COLOPHON_TOKEN by default — a group-level variable carrying the same
credential as $RELEASER_PLEASER_TOKEN, under a name that matches the
tool. Both exist while the estate is mixed, so a project can swap tools
without touching its variables. The old one goes when the last project
leaves releaser-pleaser; until then, deleting it breaks everything still
on it.
It must not be $CI_JOB_TOKEN: a tag pushed with CI_JOB_TOKEN does
not fire downstream tag pipelines (GitLab loop-prevention), so goreleaser
would never run.
What changes if you swap from releaser-pleaser¶
One thing, and it is deliberate.
colophon releases on perf and refactor. releaser-pleaser releases on
neither. So a project that swaps will see release-worthy commits it did not
see before, and a changelog entry under Other.
Measured on colophon's own history, both tools over the same commits:
- both compute the same version (
v0.1.0) - the changelogs differ by exactly one entry — the single
refactor:commit - nothing releaser-pleaser lists is missing from colophon's
Run colophon plan on a project before
swapping it and diff the answer. That is what plan is for: it computes what
colophon would do and writes nothing.
What gets simpler¶
releaser-pleaser needs a second job, a second container image, and a forced
squash: true on every Release MR. All of that exists to work around one
upstream behaviour: it picks the release commit as merge-commit, else
squash-commit, else the merge request's recorded head — and GitLab 19.2's
automatic rebase never writes a rebase back to that record. Measured 2026-08-01,
9 of 231 recent tags across phpboyscout/go were not on their default
branch, over 8 repositories.
colophon cannot reach that code path. It resolves the release commit with
forge.PullRequests.ResolveMergedCommit, which returns a commit confirmed
present on the target branch or ErrNotFound, and forge.PullRequest carries
no head-SHA field at all — there is nothing to fall back to and be wrong about.
So this component has no verify job, forces nothing on your merge requests, and pulls one image.
Not inputs, deliberately¶
Anything colophon reads from the repository has no component input, because a second place to say it is a second thing to disagree with:
| what | where it lives |
|---|---|
| version override | a Release-As: 1.2.3 trailer on a commit |
| release-note prose | a Release-Note: trailer, or a fenced release-note block, in a commit message |
| note placement and heading | .colophon.yaml |
| holding releases | hold: true in .colophon.yaml |
| whether, and where, a release is announced | announce: in .colophon.yaml — see Announcing |
A hold stops publish acting. It does not stop the version being computed
or the Release MR being maintained — a hold should make the pending release
easier to look at, not hide it.
The checkout must be full¶
Every colophon job sets GIT_DEPTH: "0". colophon reads the history back to
the last release tag, and the tags, from the job's checkout, and a shallow
clone fails every verb with:
GitLab's project default depth of 20 is shallow enough to fail. The component's
per-job setting wins over the project default; do not override it in a
default: or variables: block that reaches these jobs. The full contract a
pipeline has to meet is colophon's
pipeline requirements
page.
colophon-release-check is the one job here that runs the git CLI against the
checkout, and the runner's helper creates that checkout as root while the image
runs as uid 1000, so the job adds safe.directory for the project before its
first git command. Without it git diff a b refuses silently and prints
error: Could not access '<sha>'.
Nested tags (tag_also)¶
colophon 0.4.0 can cut <dir>/<root tag> alongside the root tag for a nested
Go module listed under tag_also in .colophon.yaml. Three things follow for
a consumer that adopts it:
- A nested tag starts a tag pipeline wherever the project's
workflow:admits$CI_COMMIT_TAG. Narrow that rule to$CI_COMMIT_TAG =~ /^v/unless the nested tag should build something. colophon-releaseand goreleaser both gate ontag_pattern, whose default matches root tags only, so a nested tag never starts a release or a build.- A
v*protected-tag pattern does not coverlint/v*; protect the nested pattern separately if it matters.
Inputs¶
| input | type | default | description |
|---|---|---|---|
image |
string | registry.gitlab.com/phpboyscout/images/release-tools:v0.1.16 |
Image carrying the colophon binary. A full reference, not a tag, so a consumer can point at a different image entirely. Renovate keeps it current. At least v0.1.9 (colophon 0.7.0) while apply is enabled — see the apply row. |
stage |
string | release |
Stage for every job in this component. One is enough — the only ordering that matters, publish before propose, comes from needs: rather than stages. |
branch |
string | main |
Target branch to release from. |
token |
string | $COLOPHON_TOKEN |
Forge API, branch push and tag push. Needs api + write_repository. Never $CI_JOB_TOKEN. |
if |
string | $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH |
Gating rule for the publish, propose and apply jobs. The schedule-never guard is always applied ahead of it. It does not reach colophon-release-check or colophon-release, whose triggers are welded to the work they do. |
assets |
boolean | false |
Requires colophon ≥ 0.2.0 (image: at least release-tools v0.1.7) — earlier images have no --tag-only/--assets and the job fails on an unknown flag. Publish the release on the tag pipeline carrying build artefacts, instead of on the default branch. When true, colophon-publish tags and stops and colophon-release creates the release afterwards. Turn it on for a repository whose releases carry binaries; leave it off for a library, whose release is a tag, a changelog and nothing to attach. Where those artefacts come from is dist's business — a project naming them in .colophon.yaml sets dist: "" alongside this. |
tag_pattern |
string | (root and component shapes, see below) | Tags colophon-release runs for. Admits the root vX.Y.Z plus the three shapes a component tag can take: <dir>/vX.Y.Z (Go's, required by the module proxy), <name>-vX.Y.Z and <name>@X.Y.Z. colophon decides which of those carries a release object, and one that carries none is reported and exits zero, so the job stays green. Not the same as goreleaser's tag_pattern any more: per-component assets are deferred, so widening goreleaser's would start a build with nothing to build. |
dist |
string | dist |
goreleaser's output directory. Its artifacts.json names what the release should carry — read rather than globbed, so an artefact colophon does not recognise is a visible failure rather than a silent inclusion. Set it to "" if the project names its own assets in .colophon.yaml (colophon ≥ 0.10.0): the manifest list and a goreleaser dist are alternatives, not a union, and this default is goreleaser-shaped, so such a project inherits a dist it does not have and is refused on its first release. See A project that names its own assets. |
asset_base |
string | (the GitLab generic package registry path) | URL the artefacts were uploaded under, with each asset's name appended. Must match goreleaser's uploads.target. Written in both places on purpose: colophon does not derive it, because deriving it would encode goreleaser's configuration inside another tool, and a mismatch is then a configuration error somebody can see rather than a release full of links that 404. |
author_name |
string | colophon |
Name on the release commit and tag. Explicit, because git otherwise takes one from the runner's ambient configuration. |
author_email |
string | [email protected] |
Email on the release commit and tag. |
release_branch |
string | "" |
Release branch to write. Empty derives colophon/release/<branch>, named after its target so a project releasing from more than one line does not collide with itself. |
release_files |
string | "CHANGELOG.md **/CHANGELOG.md" |
Space-separated globs of the files a release MR may change. colophon-release-check fails the MR if it carries anything else: under fast-forward that is how a commit never on the target branch would reach a tag. Extend it if .colophon.yaml stamps a version into other files. |
go_proxy |
string | https://proxy.golang.org |
Go module proxy colophon-publish tells about each Go module tag it cuts: one request for @v/<tag>.info once the tag is visible on the forge and after a pause, so the proxy, its index and pkg.go.dev (which go-core-currency reads) see the release within a minute. A request the proxy cannot yet satisfy caches a 404, which is why it is asked exactly once. Public projects only; never fails the job. Empty to skip. |
propose |
boolean | true |
Run the propose job. |
publish |
boolean | true |
Run the publish job. false is the safe half: opens and updates a merge request, cuts no tag. |
apply |
boolean | true |
Requires colophon ≥ 0.7.0 (image: at least release-tools v0.1.9) — 0.6.0 shipped the verb but could not start it in a repository with no colophon config file, which is most of them (colophon#15), and anything older has no apply verb at all. Run the apply job. On by default and deliberately so: every project in this estate uses specs and tickets, so being selective about which of them can say what became of a piece of work is harder to manage than running it everywhere. A push whose commits carry no trailer does nothing and says so. |
apply_allow_failure |
boolean | true |
Whether the apply job can fail without failing the pipeline. apply exits non-zero when an instruction failed (the forge was asked and refused) and when it could not start; an instruction the forge has no capability for is skipped, not failed. By then the merge has already happened, so there is nothing in the pipeline to go back and fix: left true, the job goes yellow and the failure stays visible. Set false where it should stop the line. |
announce |
boolean | true |
Let publish announce the releases it creates through the adapters .colophon.yaml names (colophon ≥ 0.13.0, image: at least release-tools v0.1.14). false passes --no-announce. Nothing announces unless the project opts in. |
feed_repo |
string | phpboyscout/blog |
Repository holding the estate's release feed. Operator settings for the file adapter, written for public projects only. Empty writes none. A path (./out) is a local directory: written, never committed. |
feed_token |
string | $GITLAB_TOKEN |
Token the file adapter commits with; the group variable, since GitLab Free has no project tokens (0100 D5). Reaches colophon as $CICD_FEED_TOKEN. |
feed_branch |
string | main |
Branch of feed_repo to commit to. |
feed_strip_prefix |
string | phpboyscout/ |
Prefix removed from the project path before it names the feed file. |
feed_path |
string | data/releases/{path_dashed}.yaml |
Path template of the per-project feed file. |
feed_message |
string | chore(releases): {path} {tag} |
Commit message the adapter uses. |
Carrying a decision to a ticket or a spec¶
colophon-apply runs on every push to the target branch, not only when a
release is cut. It reads the commits that push added and acts on the Updates:
trailers they carry:
fix(publish): read the manifest at the released commit
Updates: #13 shipped Fixed in v0.5.0.
Updates: cicd/wikis/specs/0079-pipeline-churn implemented
| target | means | what happens |
|---|---|---|
#123, cicd#123 |
an issue, here or elsewhere | a comment |
!45, cicd!45 |
a merge request | a comment |
cicd/wikis/specs/0079-thing |
a wiki page | one frontmatter field is set |
Keywords are a closed vocabulary — shipped and implemented. A word colophon
does not know is refused and reported, never guessed at.
The trailer has to be written before the feature merges
Once the commit is on the target branch it is immutable, and apply reads
commit messages. Adding the trailer when you notice the ticket is still open
is too late for that merge — it wants a new commit, or the ticket updating by
hand.
Nothing is inferred
A commit says what became of a record or the record is left alone. colophon
cannot know which merge finished a piece of work, and a record marked done
too early is worse than one left stale, because a wrong record is trusted
where a stale one is merely suspected. This is the same reasoning
Release-As: exists for.
A page whose frontmatter does not carry the field a keyword names is refused rather than grown one, and a page already carrying the value is reported unchanged and not rewritten. Re-running is safe: each comment carries an idempotency key naming the commit that asserted it, so a retried pipeline posts nothing twice.
Announcing a release¶
When publish creates a release object it announces it — through whatever
adapters the project's .colophon.yaml names, and only those. Nothing is
announced by default, and nothing here changes that: a project opts in.
Two adapters exist. discord is the project's own word and lives wholly in
the manifest:
announce:
discord:
webhook: $DISCORD_RELEASE_WEBHOOK # an environment reference, never a value
when: any # the smallest release to announce
file appends the release to the estate's feed in phpboyscout/blog, and its
settings are this component's, not the project's: for a public project
the job writes them to a colophon config file and passes it with --config.
A project enables the feed with one line, and inherits everything else:
A project block with fields lays over the operator's field by field. A
private project never gets the settings at all ($CI_PROJECT_VISIBILITY),
because a private release must not reach a public feed — and it has no
file: {} either way.
The announcement happens inside the job that creates the release,
immediately after the release object exists: colophon-publish on the default
branch for most projects, colophon-release on the tag pipeline for an
assets: true one. There is no separate announce
job and nothing to order: the old discord-release component announced from
the tag pipeline, which on a project without assets starts before the
release object exists (publish tags first, then releases), and retried
around the gap. That gap cannot open here. The verb exits 0 whatever the
adapters report — a chat message must not redden a release — and the report
gains an announce <tag> section saying announced, skipped, failed or
unchanged per adapter.
The feed's credential is the group GITLAB_TOKEN (feed_token), reaching
colophon as $CICD_FEED_TOKEN: the publish job exports GITLAB_TOKEN as the
colophon token, so a config file naming $GITLAB_TOKEN would quietly read the
wrong one.
announce: false passes --no-announce to both verbs. feed_repo: ./out
(any path) writes the feed under that directory and commits nothing — what
the self-test uses, and a dry run of what the blog would receive.
Spec 0100 and colophon spec 0026.
Usage¶
stages:
- lint
- test
- release
include:
- component: gitlab.com/phpboyscout/cicd/[email protected]
inputs:
stage: release
branch: main
Migrating in stages? Start with publish: false, which writes a branch and a
merge request and cuts nothing, and compare its Release MR against the one
releaser-pleaser is already maintaining.
Recovering with a hand-cut tag¶
When colophon itself cannot cut a release, a maintainer can push the vX.Y.Z
annotated tag by hand (v* tags are protected at Maintainer across the estate). The tag
pipeline runs as usual. For an assets: true project colophon-release runs
colophon release --tag $CI_COMMIT_TAG, which starts from the tag rather than
from the last merged release MR, so the hand-cut tag gets its release object,
assets and announcement exactly as a merged one would (colophon spec 0005 D7).
A project without assets gets the tag and its build, and the release object
is created by running colophon release --tag vX.Y.Z by hand.
colophon's runbook walks through it: Recover with a hand-cut tag.
A project that names its own assets¶
A release's files usually come from goreleaser, and dist points at where it put
them. A project that no build tool describes can instead list them in its own
.colophon.yaml (colophon ≥ 0.10.0), and then there is no dist to point at:
include:
- component: gitlab.com/phpboyscout/cicd/[email protected]
inputs:
stage: release
branch: main
assets: true
dist: ""
dist: "" is required, and it is the part that is easy to miss. The two sources
are alternatives rather than a union — colophon refuses both at once, deliberately,
so that a missing asset has exactly one place it should have come from. This
input's default is goreleaser-shaped, so a manifest project that says nothing
inherits a dist it does not have and is refused at the end of its first release
pipeline, on an error naming a flag it never passed.
Blanking it is not a workaround. dist means goreleaser's output directory, and a
project with no goreleaser has none.
asset_base still applies: it is the URL the files were uploaded under, whichever
source named them. phpboyscout/ffmpeg-wasi is the worked example in the estate.