Release Framework architecture and internals
This document explains how Release Framework is put together, where each piece lives, and why the seams sit where they do. The audience is Delivery and SRE engineers who need to debug, extend, or maintain the framework. If you are onboarding a project, start with the Onboarding doc instead.
Overview
Release Framework provisions a pair of mirrored GitLab projects (Canonical and Security) plus the supporting identities, integrations, and CI plumbing that let a project develop in the open while still handling embargoed security fixes privately. Builds and deploys for Release Framework projects are driven from the Security mirror; there is no separate Build mirror.
flowchart TB
subgraph gitlabcom["gitlab.com"]
canonical["Canonical Project<br/>public by default"]
security["Security Mirror 🔒<br/>always private"]
runway["Runway Deployment Project<br/>(optional, opt-in)"]
botgroup["Per-project SA bot subgroup<br/>gitlab-com/gl-infra/release-framework-bots/<project>"]
end
canonical -- "push mirror<br/>(SA token)" --> security
security -. "downstream bridge<br/>(Create deployment Helm chart)" .-> runway
botgroup -- "developer (group share)" --> security
botgroup -. "developer (group share, opt-in)" .-> runway
classDef canonical fill:#28a745,color:#fff
classDef security fill:#fd7e14,color:#fff
classDef bot fill:#6f42c1,color:#fff
classDef runway fill:#0366d6,color:#fff
class canonical canonical
class security security
class botgroup bot
class runway runway
An opinionated framework
Release Framework makes a small number of intentional, non-negotiable choices. Knowing which knobs exist and which don’t avoids friction during onboarding and avoids drift later:
- The canonical project is public by default.
- The security mirror is always private.
- Protected-branch push access is locked to “no one” so that only mirror tokens can write. This includes both the default branch as well as the stable branches.
- semantic-release runs only on the security mirror’s pipelines and syncs tags back to canonical via the push mirror.
- A PSIRT approval rule is always added to the security mirror in addition to whatever approval rules are configured. It cannot be turned off.
- Vault is always enabled, with the auth path hardcoded to
gitlab-com. - common-ci-tasks is enabled by default with a curated set of features (autolabels, danger, renovate, semantic-release on; goreleaser, terraform off). Individual features are toggleable per project, but the integration itself is part of the platform.
- A per-project service-account bot is created for every project. It is the load-bearing identity for both mirroring and pipelines (see §The service account below).
Variables exist where consumers legitimately need to vary behavior: approval rules, merge-request settings, common-ci-tasks toggles, share groups, selective code-owner removal, and a couple of migration-only escape hatches on branch and tag protection access levels.
Components and source-of-truth matrix
| Concern | Owned by | Where it lives |
|---|---|---|
| Module behavior, resource graph, defaults | terraform-modules/gitlab/release-platform | main.tf, service_account.tf, locals.tf, variables.tf |
| Per-project configuration (the schema you edit) | infra-mgmt | data/projects/release-platform/repos.yaml, header comment is the authoritative schema |
| Glue: data lookups, namespace resolution, runway share, imports | infra-mgmt | environments/gitlab-com/projects_release_platform.tf |
| Module-managed security namespaces map | infra-mgmt | release_platform_security_namespace_modules local in projects_release_platform.tf |
| Pipeline-side smart sync (publish/sync jobs) | common-ci-tasks | templates/release-platform/ component |
| Runway deployment project lifecycle | runway/provisioner | config/runtimes/<runtime>/workloads.yml |
| Bot’s developer access on runway deployment project | infra-mgmt | environments/gitlab-com/projects_release_platform.tf (gated on runway_deployment_path) |
The service account
This is the single most load-bearing identity in Release Framework. It is used for two things, and if either breaks, the project breaks:
- Repository mirroring. The service account’s access token is the credential the push mirror uses to write from the canonical project into the security mirror. Without it, mirroring stops, and security fixes cannot flow into the security mirror.
- Triggering downstream deploys. Pipelines on the security mirror run
as the service account because its bot subgroup (
gitlab-com/gl-infra/release-framework-bots/<project>) is shared into the security mirror at developer access. When those pipelines need to trigger theCreate deployment Helm chartbridge into the runway deployment project, the bot subgroup must also have developer access on that runway deployment project.
We do not use the GitLab Project terraform module’s default project mirroring because of the second bullet point above.
Where it lives
A per-project subgroup is created at:
gitlab-com/gl-infra/release-framework-bots/<project>
The service account user is added to that subgroup at developer access. One bot per project - there is no shared identity across projects.
Why a subgroup, not direct project membership
The gitlab-com namespace enforces a SAML group-membership lock that blocks
adding individual users to groups under it via the API. Sharing a group with
a project bypasses this lock, so all access grants for the bot go through
the per-project subgroup, not through direct project membership.
Implication: If you need to grant the bot access to additional projects later, share the bot subgroup with those projects. Do not try to add the service-account user directly.
Token lifecycle
The service-account access token is configured to rotate every 30 days with a 7-day pre-rotation window. If rotation fails, the push mirror credential goes stale and mirroring (plus everything downstream) stops. Treat token-rotation alerts on these projects as high priority.
Cross-project sharing pattern
The module exposes the bot subgroup ID as service_account_group_id. The
primary consumer of this output is infra-mgmt, which uses it to grant the
bot subgroup developer access on the runway deployment project when a project
opts into runway deployments (via runway_deployment_path). That share is
what enables the security mirror’s pipeline to trigger the downstream deploy
bridge.
See
§Identity and permissions
below.
Mirroring chain
Release Framework uses a two-mirror chain:
Canonical (gitlab.com, public by default) --push-mirror--> Security (gitlab.com, private)Behavior of the push mirror:
only_protected_branches = true, only protected refs replicate.keep_divergent_refs = true, prevents force-push collisions when the security mirror has commits that haven’t (yet) made it back to canonical.- The mirror token is the per-project service-account access token.
Branch protections:
- Canonical: the default branch is protected with
push_access_leveldefaulting to “no one”.*-stableis not managed here, it is managed by the common-ci-tasks semantic-release integration, which allows its bot to push and setsmerge_access_levelto “maintainer” so non-security fixes can still be merged to stable branches on canonical. - Security: the default branch is protected the same way.
*-stableis managed by the module withpush_access_levelset to “no one” andmerge_access_levelset to “maintainer”. The mirror bot is grantedallowed_to_pushseparately by the push-mirror module, which is what allows stable-branch mirroring to work.
Tag protections: tag creation defaults to maintainer access on both projects.
Note: Some projects in the wider GitLab estate still use a legacy three-mirror Canonical -> Security -> Build pattern. Release Framework does not use that pattern. The security mirror is the substrate for both builds and deploys. Legacy projects keeping a build mirror are out of scope for this document, see legacy mirror docs.
Identity and permissions, end to end
Putting the pieces together:
- Per-project service account exists in a per-project subgroup under
gitlab-com/gl-infra/release-framework-bots. - The bot subgroup is shared into the security mirror at developer access. This is what lets pipelines on the security mirror run as the bot.
- The bot subgroup is optionally also granted developer access on
the runway deployment project. This share is provisioned by
infra-mgmt, gated on therunway_deployment_pathfield in the project’srepos.yamlentry (gitlab_project_share_group.release_platform_bot_runway_deployment). This is what lets the security mirror’s pipeline trigger theCreate deployment Helm chartbridge. - A canonical-registry-sync deploy token is created on the canonical
project with read+write scopes for both the container registry and the
package registry. Its credentials are exposed on the security mirror as
protected CI/CD variables
CANONICAL_REGISTRY_TOKEN_USERNAMEandCANONICAL_REGISTRY_TOKEN(the value is also masked). These are what the security mirror’s publish stage uses to push images, OCI Helm charts, and packages back to canonical. - The security mirror is added to the canonical project’s CI/CD job-token allowlist so that the publish stage’s commit-on-canonical check (a JOB-TOKEN-authenticated request against canonical’s API) succeeds instead of being rejected with HTTP 403.
- The security mirror is also added to the runway deployment project’s
CI_JOB_TOKENinbound allowlist (managed by infra-mgmt viagitlab_project_job_token_scope.release_platform_security_runway_deployment). This allows the deployment project’s pipeline to download artifacts and pull OCI Helm charts from the security mirror’s container registry usingCI_JOB_TOKEN.
Who owns which grant on the runway deployment project
The runway deployment project is created and owned by runway/provisioner,
but the grants on it are split across two Terraform states. Keep them
straight:
runway/provisionerowns the project itself, itsmainbranch-protection (including theallowed_to_pushlist populated from the workload’sextra_main_push_groups), and an optional maintainershare_groupspopulated from the workload’sgroupsfield.infra-mgmtowns the developer share of the bot subgroup on the deployment project (gitlab_project_share_group.release_platform_bot_runway_deployment), gated on the project’srunway_deployment_path.infra-mgmtcreates the bot subgroup and the service-account user, exposes the subgroup ID viaservice_account_group_id, and consumes that same output to attach this share. It is this developer share that lets the security mirror’s pipeline trigger the downstreamCreate deployment Helm chartbridge.
This split is a limitation of the GitLab API, not a design preference. If
both states tried to manage a share on the same project they would fight
each other, treating the other’s reconciliation as drift. The share is
therefore anchored in infra-mgmt alongside the rest of the project’s
release-framework opt-in state.
Practical implications:
- Onboarding a release-framework project that deploys via runway requires
changes in two repos:
infra-mgmtfor the YAML opt-in viarunway_deployment_path(which creates the bot-subgroup developer share), plusrunway/provisionerfor the workload entry (project provisioning andmainbranch-protection). - The
infra-mgmtshare uses a non-authoritativegitlab_project_share_groupresource, so other shares on the runway deployment project are not destroyed by this share’s lifecycle. This is the load-bearing assumption, if upstream changes that, the wiring model needs revisiting. See gitlab-com/gl-infra/delivery#22044.
Ordering constraint: branch-protection vs. the bot share
Because the two grants above live in different states, they have a hard ordering dependency that surfaces when onboarding a new service:
runway/provisionercreates the deployment project and itsmainbranch-protection in a single apply. When the workload carriesextra_main_push_groups, that branch-protection’sallowed_to_pushnames the bot subgroup.GitLab rejects any group in
allowed_to_pushthat is not already shared into the project:422 Push access levels group does not have access to the projectThat share is the
infra-mgmtdeveloper share above, whosedata.gitlab_projectlookup requires the deployment project to already exist.
So neither state can go first while carrying the full config: provisioner
first hits the 422; infra-mgmt first hits a 404 on the not-yet-created
project. The fix is the
two-phase provisioner onboarding
sequence: provision the project without extra_main_push_groups, let
infra-mgmt create the share, then add extra_main_push_groups in a second
provisioner apply.
Configuration expectations and defaults
What the module fills in for you when you say nothing:
- Default branch:
main. - Canonical visibility:
public. - Security visibility: always
private(not configurable). - Approval rules: CODEOWNERS with 1 approval on canonical. The security mirror gets the canonical rules plus a mandatory PSIRT approval rule that cannot be disabled.
- common-ci-tasks defaults: autolabels, danger, renovate, semantic-release
on; goreleaser, terraform off. On security mirrors,
renovate_botis forced off (dependency updates run only on canonical) andsemantic_release_branch_protectionis forced off (the mirror module owns*-stableon the security side). - Vault: always enabled, auth path
gitlab-com. - Protected-branch push access:
"no one"(escape-hatch variable exists for migration). - Protected-tag create access:
"maintainer"(escape-hatch variable exists for migration). - Reset approvals on push: on by default. Selective code-owner removal is mutually exclusive and opt-in.
Module-managed vs pre-existing security namespaces
The namespace-resolution machinery in projects_release_platform.tf
distinguishes between two cases:
- Pre-existing security groups (e.g.,
gitlab-org/security). These already exist ongitlab.comand are looked up via a Terraform data source. - Module-managed security groups (e.g.,
gitlab-org/security/auth,gitlab-org/security/ops,gitlab-org/security/orbit). These are created byinfra-mgmtitself, so they cannot be looked up via a data source, there is no group to look up until Terraform creates it. They are wired through a literal map (release_platform_security_namespace_modules) that points at the relevant group module’s output.
To add a new module-managed security namespace:
- Create the group module file (e.g.,
environments/gitlab-com/group_gitlab-org-security-foo.tf). - Add an entry to
release_platform_security_namespace_modulesinprojects_release_platform.tf, mapping the namespace’s full path to the module reference.
Pre-existing namespaces do not need any wiring, they’re picked up
automatically by the data-source lookup as soon as a repos.yaml entry
references them.
Importing existing projects
Warning
Imports are operational hazards if they fail. When an import is in flight, the project is in a fragile state. If the apply fails, whether from a state mismatch, a missing import block for a resource that exists, or drift on a resource that’s already there, Atlantis on the next run can interpret the unmanaged-but-already-existing resources as drift to reconcile. Depending on the resource, that reconciliation can include archival or destruction.
Resolve import failures promptly. Do not leave an in-flight import overnight. If you need help unwinding an import, contact the Delivery team.
For the step-by-step procedure, see Importing existing projects in the onboarding doc. This section catalogs what can be imported and the operational hazards.
What can be imported includes:
- The Canonical and Security projects resource.
- Pre-existing approval rules, including renames (e.g., the iam project’s “All Members” rule was imported and renamed to “CODEOWNERS” on first apply).
- Other resources the module manages, the pattern is the same in each
case:
import { to = module... ; id = "<gitlab-id>" }.
Limitations and known sharp edges
- SAML group-membership lock under
gitlab-com. The reason every bot access grant goes through group sharing rather than direct membership. If a future change tries to add the SA user directly somewhere, it will fail. - Drift sensitivity of the runway-deployment bot share. The cross-repo
wiring relies on
infra-mgmt’sgitlab_project_share_groupresource (gitlab_project_share_group.release_platform_bot_runway_deployment) being non-authoritative. If that ever changes, the wiring location for the bot share must be reconsidered. - Consumer-pipeline flag. The
common-ci-tasksconsumer-pipeline flagrelease_platform_enabled: truemust be set on the consumer project’s.gitlab-ci.ymlfor publish/sync to work end-to-end. - Imports are operational hazards if they fail, see the callout above.
Source pointers
Quick-jump reference for future maintainers:
- Module:
terraform-modules/gitlab/release-platform/main.tf- canonical/security project modules, deploy token, job-token allowlist, push-mirror module wiring.service_account.tf- bot subgroup, service account, access token, group sharing into security mirror.locals.tf- branch/tag protection definitions, approval-rule composition (canonical default + PSIRT on security), common-ci-tasks overrides for the security side.variables.tf- full list of inputs and defaults.
- Consumer configuration:
infra-mgmt/data/projects/release-platform/repos.yaml- per-project YAML and the authoritative schema (in the file’s header comment).environments/gitlab-com/projects_release_platform.tf- namespace resolution, runway-deployment share, and import blocks.
- Pipeline-side:
common-ci-tasks/templates/release-platform/. - Runway:
runway/provisioner/config/runtimes/<runtime>/workloads.yml.