Skip to content

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:

ERRO reading commits: iterating commits: object not found

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-release and goreleaser both gate on tag_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 cover lint/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:

announce:
  file: {}

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.