# 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. `security.scope` states, in one paragraph after the reporting channel, what the project does not own and where a reporter goes instead; empty states nothing.
- The release request is a merge request against the trunk. On the rust pair, release-plz cannot refresh a request by force-push here, so it 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. On the bash and python pairs, the rk engine keeps one request per base on `rk-release/<base>` and refreshes it in place by force-push, so the request keeps its number and the next refresh drops a commit a person pushed there. 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.
- On the rust pair, 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.
- On the bash and python pairs, the release jobs run `rk release request`, `rk release build`, and `rk release publish` on a Debian image pinned by digest, after fetching glab, git-cliff, and `rk` at their pins, and the `provenance` job signs between the build and the publish. Where the project records a `[dev_environment]` answer, they run in the recorded image through the recorded prefix. The [bash binding](../bindings/bash.md) and the [python binding](../bindings/python.md) state the jobs.
- The bot identity is a project access token, or on a GitLab.com Free group a group service account that is a member of this one project at Maintainer. Either way it holds the token the release jobs use. A push authenticated with the default CI job token starts no pipeline, which is why the token exists at all. The release commit is authored by the account the token belongs to, read from `GET /user`, so it links to that account.
## Bootstrap
`rk setup step install-bot --target . --apply` is the whole bootstrap. It chooses the identity form from what the forge and the tier allow when the step runs, prints the reading as a `note:` line, and records the form nowhere, because the next run reads the tier again.
- A self-managed or Dedicated instance takes a project access token with any license. Such an instance answers `plan: "default"` whatever its license, so the step does not read the namespace there.
- On GitLab.com, a Premium or Ultimate namespace takes a project access token. A trial is allowed one. `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 caller needs the Maintainer role on the project.
- On GitLab.com, a Free group takes a group service account. Service accounts are available on Free since GitLab 18.11, and a Free top-level group holds up to 100 of them. A trial has them once the top-level group Owner verified their identity. `POST /groups/:id/service_accounts` creates the account with no email, so GitLab generates its noreply address. `POST /projects/:id/members` grants it this one project at Maintainer, and `POST /groups/:id/service_accounts/:user_id/personal_access_tokens` mints its token with the same scopes and expiry. The caller needs the Owner role on the top-level group.
- A GitLab.com Free personal namespace has no form. The step refuses before any write: move the project under a group, or use a paid tier.
The bot's name is `setup.bot.name` in `.release-kit/config.toml`. Empty takes the default: `release-bot` for a project access token, and `release-bot-<project id>` for a service account, because a service-account username is unique across the whole instance and a last path segment is not. No landed byte carries the name: the pipeline pushes as `oauth2`, so a rename moves no rendered file. The name holds to the shape of a GitLab username: a letter or a digit, then letters, digits, `.`, `_`, or `-`. Every token the step mints carries the description `release-kit bot identity`, so the live bot is found by that mark, or by the old default name, and never by its spelling alone; an unrelated service account that is a member of the project is not the bot. A rename over the live bot refuses and names both, because it would strand the first one. An account under the configured name that is not a member of this project refuses before any grant, so one identity never reaches two projects.
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 depends on the form. On a project access token, run the same step once the old token 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. On a service account, `rk setup step bot-secrets --target . --apply` mints a fresh token on the account and stores it, which is the rotation; a full `rk setup --apply` leaves a stored token alone and mints only where the variable is missing, and an exported `RK_BOT_TOKEN` still overwrites the variable.
`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` |
| Read the namespace's tier | `GET /namespaces/:id`, fields `plan`, `kind`, and `trial` |
| Create a service account | `POST /groups/:id/service_accounts` with `name` and `username` |
| Find a service account | `GET /groups/:id/service_accounts`, matched by `username` |
| Grant a service account a project | `POST /projects/:id/members` with `user_id` and `access_level=40` |
| Read its membership | `GET /projects/:id/members/all/:user_id` |
| Mint a service account's token | `POST /groups/:id/service_accounts/:user_id/personal_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.
- On GitLab.com a project access token needs Premium or Ultimate, so a Free group's bot is a group service account, created by the top-level group Owner rather than by a project Maintainer, and a Free personal namespace has no bot identity at all.
- 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.
- A GitLab release carries no prerelease badge, so a candidate's page names the candidate on the first line of its description, and on a rust pair also names the command that installs it. The latest-release permalink follows `released_at`, so a candidate takes it until the next stable release, where the sibling forge keeps the stable release as its latest.
- 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 ran against a live project on 2026-09-14, a GitLab.com Free-tier group, through setup, the bot bootstrap, and a release with provenance. That run observed the `-pre` suffix the version endpoint answers on GitLab.com, the tier rule that keeps project access tokens off a Free namespace, the service-account path end to end, the bot arming and merging its own request, `only_allow_merge_if_pipeline_succeeds` holding a merge until the pipeline passed, and a retried job running the configuration of its own commit. Two behaviours are still read rather than observed, because the run did not reach them: that on the rust pair a new push closes and replaces an open release request instead of force-pushing it, and that the `project-jobs` bridge mirrors a child pipeline's status, since that project declared no `.gitlab/ci/project.yml` yet. Read `rk setup check` rather than assuming a step did what it claims.