Set first deployed to production for tag deployments

What does this MR do and why?

A project that deploys production from a tag pipeline never gets "Merge request first deployed to production" on its merge requests. Value stream stages that end on that event stay empty, including the default Staging stage. DORA lead time still has a value.

When a deployment succeeds, GitLab sets the event on merge requests whose target branch equals the deployment's ref. A tag is never a target branch, so a tag deployment matches no merge request. DORA lead time reads the merge requests that LinkMergeRequestsService links to the deployment, so the two disagree on one deployment.

Closes #606520

flowchart TD
    A[Production deployment succeeds] --> B{Deployed from a tag?}
    B -- no --> C[Take merge requests that target the ref and merged since the previous deployment]
    B -- yes --> D[Link the merge requests new to this deployment]
    D --> E[Take the linked merge requests]
    C --> F[Set the event on those that do not have it yet]
    E --> F
    classDef changed fill:#fff3cd,stroke:#b58900,color:#333
    class D,E changed

What changed

  • A tag deployment to a production environment links its merge requests from the deployed commits, as DORA does, and sets the event on the linked ones. Deployments::UpdateEnvironmentService does the linking and Deployment#update_merge_request_metrics! picks the linked merge requests. Deployments::LinkMergeRequestWorker runs the same service for the deployment. Its inserts skip rows already linked.
  • With no previous deployment, Deployments::LinkMergeRequestsService links the merged merge requests of the default branch when it contains the tag commit. It looked for a branch named like the tag and found none. As for a branch deployment, this first link takes every merged merge request it finds.
  • DORA lead time for an environment's first tag deployment counts the merge requests this link finds. Before, that deployment linked none.
  • Branch deployments take the same path as before.
  • The value stream analytics page said production environments are found by name patterns. The code reads the deployment tier, so the page now says that and what a tag deployment counts.

Queries

A tag deployment replaces the target branch and previous deployment conditions of the existing update with a lookup of the deployment's linked merge requests by the primary key of deployment_merge_requests:

UPDATE "merge_request_metrics" SET "first_deployed_to_production_at" = '2026-10-19 03:25:52.882238'
WHERE "merge_request_metrics"."merge_request_id" IN (
  SELECT "merge_requests"."id" FROM "merge_requests"
  INNER JOIN "merge_request_metrics" ON "merge_request_metrics"."merge_request_id" = "merge_requests"."id"
  WHERE "merge_requests"."target_project_id" = 7
    AND "merge_request_metrics"."target_project_id" = 7
    AND "merge_request_metrics"."first_deployed_to_production_at" IS NULL
    AND (merge_request_metrics.merged_at <= '2026-10-19 03:25:52.882238')
    AND "merge_requests"."id" IN (
      SELECT "deployment_merge_requests"."merge_request_id" FROM "deployment_merge_requests"
      WHERE "deployment_merge_requests"."deployment_id" = 19))
  AND "merge_request_metrics"."first_deployed_to_production_at" IS NULL

The plan below is from Database Lab (gitlab-production-main) for a gitlab-org/gitlab deployment with 9 linked merge requests. The deployment_merge_requests primary key drives the lookup. The update took 135 ms on a cold clone and 0.2 ms with the pages cached. None of the 9 matched first_deployed_to_production_at IS NULL, so the update touched no rows.

Plan
Update on merge_request_metrics  (cost=7247.44..7250.36 rows=0 width=0)
  ->  Nested Loop  (cost=7247.44..7250.36 rows=1 width=32)
        Join Filter: (merge_request_metrics_1.merge_request_id = merge_request_metrics.merge_request_id)
        ->  HashAggregate  (cost=7246.86..7246.87 rows=1 width=38)
              Group Key: merge_requests.id
              ->  Nested Loop  (cost=1.72..7246.86 rows=1 width=38)
                    ->  Nested Loop  (cost=1.15..7243.38 rows=1 width=28)
                          ->  Index Scan using deployment_merge_requests_pkey on deployment_merge_requests  (cost=0.57..1481.01 rows=1604 width=14)
                                Index Cond: (deployment_id = 1626295851)
                          ->  Index Scan using merge_requests_pkey on merge_requests  (cost=0.57..3.59 rows=1 width=14)
                                Index Cond: (id = deployment_merge_requests.merge_request_id)
                                Filter: (target_project_id = 278964)
                    ->  Index Scan using unique_merge_request_metrics_by_merge_request_id on merge_request_metrics merge_request_metrics_1  (cost=0.57..3.48 rows=1 width=10)
                          Index Cond: (merge_request_id = merge_requests.id)
                          Filter: ((first_deployed_to_production_at IS NULL) AND (target_project_id = 278964) AND (merged_at <= now()))
        ->  Index Scan using unique_merge_request_metrics_by_merge_request_id on merge_request_metrics  (cost=0.57..3.47 rows=1 width=10)
              Index Cond: (merge_request_id = merge_requests.id)
              Filter: (first_deployed_to_production_at IS NULL)

References

  • #606520
  • #538310 is a related report with a different cause and is not changed here.

How to set up and validate locally

On a project with a main branch, add a job that runs only for tags and deploys to a production environment:

deploy:
  rules:
    - if: $CI_COMMIT_TAG
  environment:
    name: production
  script:
    - echo deploy
  1. Merge a merge request into main, create the tag v1.0.0 from main and wait for the tag pipeline to deploy.
  2. Merge a second merge request, create the tag v1.1.0, merge a third merge request and wait for the second deployment.
  3. Read first_deployed_to_production_at from GET /projects/:id/merge_requests/:iid for each merge request after each deployment.
Merge request Before With this change
Merged before v1.0.0 null set when v1.0.0 deploys
Merged before v1.1.0, after v1.0.0 null set when v1.1.0 deploys
Merged before v1.0.0, read after v1.1.0 null unchanged since v1.0.0
Merged after v1.1.0 was created null null

A first deployment from a tag on a branch that is ahead of main leaves the event null. A deployment from main sets the event as before. An environment named live counts as production, and production with deployment_tier: staging does not.

Run the specs:

bundle exec rspec spec/models/deployment_spec.rb spec/services/deployments

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

An AI coding assistant wrote this change. It ran the changed spec files and the other specs that touch deployments, merge request metrics and value stream stage events, RuboCop, and a mutation pass over the changed lines on test VMs. It ran Vale and markdownlint on the changed page. It ran the tag, branch and environment scenarios above on a GitLab 19.5.0 test instance with this change loaded, and the query plan on Database Lab.

Merge request reports

Loading
Loading