release-kit 0.9.9

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

Vulnerability reports use confidential issues, with confidentiality selected by the reporter and viewing governed by project access. The required `private-vulnerability-reporting` step reads issue intake configuration without changing it and names the limitation: GitLab has no project-level private reporting switch, and feature access cannot prove every external reporter can submit. The landed `SECURITY.md` describes the procedure on the project's own instance. Where the confidential issue form is unavailable the policy names a fallback contact, which `security.contact` in `.release-kit/config.toml` supplies; empty keeps this forge's own wording, an existing private conversation with a maintainer. `security.response` states the acknowledgment window the same policy promises.

- 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. `merge_method=ff` also refuses a branch that has not been rebased on the trunk: freshness is enforced through the merge method rather than a separate switch, and `rk setup check` judges it there. 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. Under `git.merge_form = "rebase"` no merge request squashes and no squash template applies: the landed `mr-commits` job judges every commit between the target branch and the request's head in place of `mr-title`, and each push starts a new pipeline, so a pushed commit is judged too. The `ff` method without squash adds the source branch's commits as they stand, a merge commit inside the branch included, so that job first refuses any commit with more than one parent in the same range, naming it, and only then applies the bot's title exemption to the message rules: no request, the bot's included, carries a merge commit onto the trunk.
- 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 policy is floored here too. `protection.gitlab.merge_method` is `ff`, `squash_option` is `always` under the squash merge form and `never` under rebase, `push_access_level` is zero, and `squash_commit_template` must reference `%{title}`; the template names no `%{description}`, because the merge request's description does not reach the trunk on this forge. `merge_access_level` floors at thirty and defaults to forty, so a project may loosen it to a developer level deliberately and never by omission.
- GitLab has no ruleset, so `protection.trunk_ruleset`, `protection.tag_ruleset`, `protection.lines_ruleset`, and `protection.title_check` name nothing here and no step reads them. A protection is addressed by the branch or tag pattern it covers, which comes from `git.trunk` and `profile.release.line_prefix`. The parity rule binds step names rather than variables, so a variable one forge alone consumes breaks nothing.
- 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 release jobs install release-plz into the `rust:1` image by default. Where the project records a `[dev_environment]` answer in `.release-kit/config.toml`, they run in the recorded `gitlab.image` and call release-plz through the recorded prefix. The image carries `curl` and `sed`, which the token exchange and the request arm call outside the prefix. The [rust binding](../bindings/rust.md#the-development-environment) states the answer.
- 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.

`rk init --code-scanning semgrep` lands `.gitlab/ci/code-scanning-semgrep.yml`, and the rendered `.gitlab-ci.yml` includes it under `rules: - exists:` naming the same path, because a local include of an absent file fails the whole pipeline and a target that did not opt in has no such file. Semgrep Community Edition rather than this forge's own SAST template: GitLab SAST is free at every tier, and it ships no GitLab-managed Rust ruleset, so the template would run and scan nothing. The job runs on the trunk and the release lines and never on a merge request, because this forge's merge check is the whole pipeline and a scan that blocked a merge would block it on a finding the author cannot triage in the request. Its result is a CI artifact rather than a merge-request widget: the vulnerability report that reads a SAST artifact is an Ultimate-tier feature, so on every other tier the artifact is what a reader opens. `codeql` is GitHub's own analyzer and this forge refuses that pair by name rather than recording an answer and writing nothing, and so does a binding that ships no scanner. The capability adds no setup step.

## 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. gitlab.com reports its version with a `-pre` suffix on every deployment, and the step reads the number alone there. A self-managed instance that reports a pre-release suffix still reads unsatisfied.
- 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. The commit gate keeps the first hole and not the second: an author can edit the `mr-commits` job out of their own request's pipeline, and every push starts a pipeline that judges the commits again.
- 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.