release-kit 0.3.2

A canonical release workflow: a technology-agnostic method, per-technology bindings, and the rk CLI that lands and serves them.
Documentation
# GitLab

How this forge answers the method's fifth axis. The CLI is `glab`, and `rk setup` runs the `setup/gitlab/` tree against it. The project path may nest deeper than two levels; every API call URL-encodes it.

## Answers

- The release request is a merge request against the trunk. Force-push refresh is unavailable here, so the bot keeps a request current by closing the open one and opening a fresh one when new work lands — and a correction on the closed request goes with it. The changelog window in the operate chapter is therefore narrower on this forge: correct and merge, with nothing landing in between.
- The gate is the release request's own merge, enforced by the project setting `only_allow_merge_if_pipeline_succeeds`. That is real enforcement — a Maintainer cannot merge past a failing pipeline — with a different shape: it is project-wide rather than branch-targeted, it requires the whole pipeline rather than a named job, and it blocks a merge when there is no pipeline at all. There is no check name to register and nothing for one to point at.
- Linear trunk history is the project setting `merge_method=ff` plus squash on every merge request: the forge that can fast-forward, does, so the trunk takes no merge commits. The squash message is the merge request's title because `squash_commit_template` is `%{title}` — this forge's documented default, which `protect-trunk` asserts rather than assumes — and the landed `mr-title` job holds that title to the scoped convention, blocking through the pipeline requirement since this forge names no check; the Limitations below state what that job stops and what it cannot.
- Protections are protected branches and protected tags. A protected branch updates in place through `PATCH /projects/:id/protected_branches/:name`; protected tags expose no update endpoint, so a change is delete-then-create and a rerun is briefly not atomic.
- The issue link is a name match: a branch whose name starts with the issue's own iid followed by a hyphen cross-links, and the merge request opened from it carries `Closes #<issue>` by default. That prefix is the whole rule, so a name of any other shape links to nothing, whatever else it carries. The name comes from the project setting `issue_branch_template`, whose variables are `%{id}`, `%{title}`, and `%{branch_creator}`, and GitLab applies that template in the web UI alone: no API endpoint renders it, and the Branches API creates a branch from a name and a ref it is given. So `rk issue start` reads the project's template, renders it the way GitLab renders it, and then creates the branch. A confidential issue is the one case the template does not reach: GitLab names its branch `<iid>-confidential-issue`, so no branch name carries a confidential title.
- The bot identity is a project access token: creating the token also creates its bot user, in one API call. A push authenticated with the default CI job token starts no pipeline, which is why the token exists at all.

## Bootstrap

Nothing here is manual. `rk setup step install-bot --target . --apply` is the whole bootstrap: `POST /projects/:id/access_tokens` creates the token and its bot user together, with the `api` and `write_repository` scopes at Maintainer access, expiring roughly a year out.

The create response is the only time the forge shows the token's value, so the same step immediately stores it as the masked CI variable `RELEASE_BOT_TOKEN` rather than printing it and asking an operator to copy it; the value never appears in any output or record.

Rotation is two commands: create a replacement token through the same step once the old one nears expiry, or export a value as `RK_BOT_TOKEN` and run `rk setup step bot-secrets --target . --apply` to overwrite the stored variable, the value travelling on standard input.

## Project jobs

The rendered pipeline triggers a child pipeline from `.gitlab/ci/project.yml`. That file is the project's, created by the project and named by convention, and it is where a project declares jobs of its own.

- A project job's failure reaches the merge-request pipeline through the `project-jobs` bridge, which triggers the child with `strategy: mirror`. The merge check here reads the whole pipeline, so this forge needs no aggregate gate job and there is still no check name to register.
- The project owns `.gitlab/ci/project.yml` and may declare its own stages and its own nested includes inside it. A child pipeline is a separate configuration, so its globals, its `default` block, and its job names never merge into the rendered parent. An include cannot give that: this forge performs a reverse deep merge, so a lower-precedence file contributes every key the higher-precedence mapping omits, whatever the include order.
- A child job needs no `merge_request_event` rule. The bridge already scopes the child to a merge request, and inside a child pipeline `CI_PIPELINE_SOURCE` reads `parent_pipeline`. Use `CI_MERGE_REQUEST_ID` where a rule is wanted.
- A job meant to gate must not carry `allow_failure: true` or an optional `when: manual`. Either one lets the child pipeline finish without the proof, and the parent then reads success.
- GitLab 18.2 is the supported minimum, because `strategy: mirror` needs it. An older self-managed instance cannot run this convention, and `rk setup step forge-version` refuses before `protect-trunk` installs a merge check the child pipeline cannot feed.
- The title gate's authority limitation below binds the bridge too: a merge request runs its own branch's configuration, so an author can still edit the rendered parent out of their own request. The bridge stops accident, not authority.

## Mapping

| Purpose                               | Command                                                                |
| ------------------------------------- | ---------------------------------------------------------------------- |
| Raw API                               | `glab api`                                                             |
| Set the default branch                | `glab api -X PUT projects/:id` with `default_branch`                   |
| Delete a branch when its merge lands  | `glab api -X PUT projects/:id` with `remove_source_branch_after_merge` |
| Store a secret                        | `glab variable set NAME --masked` with the value on stdin              |
| List open release requests            | `glab mr list --target-branch <branch>`                                |
| Merge the release request             | `glab mr merge --squash --remove-source-branch`                        |
| Wait on checks                        | `glab ci status --wait`                                                |
| Wait on a build                       | `glab ci status --wait`                                                |
| Find the merged request for a commit  | `glab api projects/:id/repository/commits/<sha>/merge_requests`        |
| Read the branch name template         | `glab api projects/:id`, field `issue_branch_template`                 |
| Find an issue's branch by its prefix  | `glab api projects/:id/repository/branches?search=^<iid>-`             |
| Create the branch for an issue        | `glab api -X POST projects/:id/repository/branches` with the name      |
| Create it and a merge request at once | `glab mr create --related-issue <issue> --create-source-branch`        |
| Protect a branch                      | `POST /projects/:id/protected_branches`, `PATCH` to update             |
| Require the trunk's checks            | `PUT /projects/:id` with `only_allow_merge_if_pipeline_succeeds`       |
| Set the merge method                  | `PUT /projects/:id` with `merge_method=ff`                             |
| Make the title the squash message     | `PUT /projects/:id` with `squash_commit_template=%{title}`             |
| Protect tags                          | `POST /projects/:id/protected_tags`; no `PATCH`, so delete-then-create |
| Grant the bot access to a project     | `POST /projects/:id/access_tokens`                                     |
| Find the bot identity                 | `GET /projects/:id/access_tokens`                                      |

## Limitations

- Tag immutability is weaker than the method asks. Protected tags stop git clients and non-privileged users, but a project Owner or Maintainer can still delete a protected tag through the UI or the API: protection against accident, not against authority. `rk setup check` reports that weaker guarantee by name rather than a pass, and the invariant survives as an invariant of the method, held by convention where the forge stops.
- The gate names no check. Per-check enforcement exists only as external status checks in the Ultimate tier, so `--required-check` is a usage error on this forge rather than a value silently discarded.
- The title gate stops accident, not authority. A merge request pipeline runs the source branch's CI configuration, so an author can edit the `mr-title` job out of their own request's pipeline — running it from configuration the request cannot touch takes a pipeline execution policy, an Ultimate-tier feature. And editing a request's title starts no new pipeline, so a title changed after its pipeline passed is merged unrechecked. The sibling forge closes both holes — `pull_request_target` runs the trunk's copy, and the check re-runs on an `edited` event; here the convention holds by the same authority-versus-accident line the protected-tag limitation already states.
- No API renders an issue's branch name. The template is a project setting the web UI applies, so a client that wants the same name must reproduce GitLab's own rendering. `glab mr create --related-issue --create-source-branch` composes the name in the client instead: it applies no template, transliterates nothing, squeezes no separator runs, and truncates nothing, so it can produce a different name for the same issue. It also opens a merge request before the first commit exists. `rk issue start` therefore renders the name itself, and reports the one case its transliteration table can differ on.
- Registry trusted publishing that supports this forge covers GitLab.com only, in public beta; a self-hosted instance cannot satisfy the OIDC invariant and falls back to a long-lived token, which is what the invariant exists to remove. `rk setup` reports this at the first step rather than letting it surface when the trusted publisher will not register.
- Keyless provenance signing covers GitLab.com only, for the same OIDC reason: Sigstore's public instance trusts `gitlab.com` as an issuer and no self-managed `CI_SERVER_URL`, and the `id_tokens` configuration must live in the project being built and signed — no AutoDevOps, no CI files included from other repositories, no child pipelines. A landed pipeline states the limitation at run time and releases without provenance on a self-managed instance rather than failing; [the bash binding](../bindings/bash.md) carries what the signed statement is and how a consumer verifies it.
- The `(rust, gitlab)` pair has no artifact builder, so its release page carries no installers; [the Rust binding](../bindings/rust.md) carries that fact, because it is a property of the pair.
- This tree is younger than the GitHub one. Every step is tested against a mocked CLI and every command below is taken from this forge's own documentation, but the tree has not yet run against a live project, and three behaviours in particular are read rather than observed: that a new push closes and replaces an open release request instead of force-pushing it, that `only_allow_merge_if_pipeline_succeeds` blocks a Maintainer's merge rather than only a Developer's, and that the `project-jobs` bridge compiles and mirrors a child pipeline's status as documented. Treat a first setup here as the proving ground it is, and read `rk setup check` rather than assuming the step did what it claims.