Skip to content
Release Framework architecture and internals

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/&lt;project&gt;"]
    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

ConcernOwned byWhere it lives
Module behavior, resource graph, defaultsterraform-modules/gitlab/release-platformmain.tf, service_account.tf, locals.tf, variables.tf
Per-project configuration (the schema you edit)infra-mgmtdata/projects/release-platform/repos.yaml, header comment is the authoritative schema
Glue: data lookups, namespace resolution, runway share, importsinfra-mgmtenvironments/gitlab-com/projects_release_platform.tf
Module-managed security namespaces mapinfra-mgmtrelease_platform_security_namespace_modules local in projects_release_platform.tf
Pipeline-side smart sync (publish/sync jobs)common-ci-taskstemplates/release-platform/ component
Runway deployment project lifecyclerunway/provisionerconfig/runtimes/<runtime>/workloads.yml
Bot’s developer access on runway deployment projectinfra-mgmtenvironments/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:

  1. 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.
  2. 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 the Create deployment Helm chart bridge 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_level defaulting to “no one”. *-stable is not managed here, it is managed by the common-ci-tasks semantic-release integration, which allows its bot to push and sets merge_access_level to “maintainer” so non-security fixes can still be merged to stable branches on canonical.
  • Security: the default branch is protected the same way. *-stable is managed by the module with push_access_level set to “no one” and merge_access_level set to “maintainer”. The mirror bot is granted allowed_to_push separately 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:

  1. Per-project service account exists in a per-project subgroup under gitlab-com/gl-infra/release-framework-bots.
  2. 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.
  3. The bot subgroup is optionally also granted developer access on the runway deployment project. This share is provisioned by infra-mgmt, gated on the runway_deployment_path field in the project’s repos.yaml entry (gitlab_project_share_group.release_platform_bot_runway_deployment). This is what lets the security mirror’s pipeline trigger the Create deployment Helm chart bridge.
  4. 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_USERNAME and CANONICAL_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.
  5. 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.
  6. The security mirror is also added to the runway deployment project’s CI_JOB_TOKEN inbound allowlist (managed by infra-mgmt via gitlab_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 using CI_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/provisioner owns the project itself, its main branch-protection (including the allowed_to_push list populated from the workload’s extra_main_push_groups), and an optional maintainer share_groups populated from the workload’s groups field.
  • infra-mgmt owns the developer share of the bot subgroup on the deployment project (gitlab_project_share_group.release_platform_bot_runway_deployment), gated on the project’s runway_deployment_path. infra-mgmt creates the bot subgroup and the service-account user, exposes the subgroup ID via service_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 downstream Create deployment Helm chart bridge.

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-mgmt for the YAML opt-in via runway_deployment_path (which creates the bot-subgroup developer share), plus runway/provisioner for the workload entry (project provisioning and main branch-protection).
  • The infra-mgmt share uses a non-authoritative gitlab_project_share_group resource, 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/provisioner creates the deployment project and its main branch-protection in a single apply. When the workload carries extra_main_push_groups, that branch-protection’s allowed_to_push names the bot subgroup.

  • GitLab rejects any group in allowed_to_push that is not already shared into the project:

    422 Push access levels group does not have access to the project
  • That share is the infra-mgmt developer share above, whose data.gitlab_project lookup 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_bot is forced off (dependency updates run only on canonical) and semantic_release_branch_protection is forced off (the mirror module owns *-stable on 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:

  1. Pre-existing security groups (e.g., gitlab-org/security). These already exist on gitlab.com and are looked up via a Terraform data source.
  2. Module-managed security groups (e.g., gitlab-org/security/auth, gitlab-org/security/ops, gitlab-org/security/orbit). These are created by infra-mgmt itself, 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:

  1. Create the group module file (e.g., environments/gitlab-com/group_gitlab-org-security-foo.tf).
  2. Add an entry to release_platform_security_namespace_modules in projects_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’s gitlab_project_share_group resource (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-tasks consumer-pipeline flag release_platform_enabled: true must be set on the consumer project’s .gitlab-ci.yml for 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.
Last updated on