Skip to content

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.

release_notes:
  standing:
    prefix: docs/release-preamble.md
    suffix: docs/release-verification.md

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.md and 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. plan and propose warn 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: true on a shared component 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: true on a shared component creates a release object on its tag as publish cuts it, or as release --tag runs 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: false on an own component still tags it and writes its changelog; only the release object is withheld.
  • changelog: false on an own component is refused. Its changelog heading is where its version is recorded between propose and publish; 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. plan lists such commits, so a directory nobody declared is visible.
  • The top-level hold holds every component, with nothing else to hold. A component's own hold still holds just that one.
  • The top-level standing block is still what components inherit.
  • release --tag v1.2.3 is 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 shared component, 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.

announce:
  discord:
    webhook: $DISCORD_RELEASE_WEBHOOK
    when: minor

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:

announce:
  file: {}          # the feed the operator configured, as configured

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:

assets:
  - engine.wasm
  - engine.wasm.gz
  - checksums.txt
  - checksums.txt.sig
  - provenance.json

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, not dist/engine.wasm. The name is escaped into a URL, so a separator would point at nothing.
  • The same name twice, and an empty entry.
  • assets together 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.

versioning:
  scheme: ordinal
  prefix: n9.0.1

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:

n9.0.1-5   ->  a feature lands   ->  n9.0.1-6
n9.0.1-6   ->  the prefix moves  ->  n9.0.2-1

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:

versioning:
  scheme: ordinal
  prefix_file: build/ffmpeg-version.txt

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 --tag reads 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-5 and the manifest says n8.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.
  • prefix and prefix_file together, and neither. They are alternatives and one is required.
  • A prefix beside scheme: semver, which means nothing and is therefore a typo.
  • components beside scheme: 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:

versioning:
  tag: "rtb-error-v{version}"

{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.3 is the last release on the first run, and a v tag the repository carries from before the template is not.
  • propose writes the changelog heading as the rendered tag, ## [rtb-error-v0.6.4], and publish cuts exactly that.
  • release --tag rtb-error-v0.6.4 is a root tag, like v0.6.4 without 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.
  • tag beside scheme: ordinal, whose version is already its tag.
  • tag beside tag_also, whose <dir>/<root tag> is Go's nested-module shape and assumes a v root.
  • 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:

versioning:
  files:
    - cargo: Cargo.toml

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] version in a workspace, otherwise [package] version.
  • Every workspace member. These come from members, with globs expanded and exclude honoured. A member must inherit the workspace's version (version.workspace = true) unless it sits in an own component, where it takes that component's version.
  • Requirements on a member. These are the version of any dependency whose path points 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.lock beside 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 a source line 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.
  • files beside scheme: 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:

hold: true      # also True, TRUE
hold: false     # also False, FALSE

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 called null; an unquoted null still names no directories. Names, paths and headings are read as written, so an unquoted 2024 is a directory called 2024 rather than an integer.
  • A field written twice is refused, rather than one of the two winning silently. A manifest with two hold keys 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:

    mode: merge_request
    merge: when_ready     # optional: ask the forge to merge once its checks pass

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.