Enforce project read-only state while GitHub sync is on

What does this MR do and why?

While a project is synced from a connected GitHub repo, GitLab's side needs to stay read-only. Otherwise changes in GitLab could get overwritten by the next sync, or the two copies drift apart with no way to tell which one is right. The same check needs to stop GitLab's own legacy mirroring from running against a project that GitHub already owns.

This logic already existed in a proof-of-concept MR (feature/github-connected-project-poc, !251883 (closed)), which is being closed. This MR ports the read-only and mirror-exclusion piece out of that branch into a fresh, reviewable MR. Everything is behind the github_continuous_import feature flag (type wip, default off), so it has no effect on any real project yet. Enforcement itself also has its own separate ops kill-switch flag, github_continuous_import_enforcement (default on), decoupled from github_continuous_import on purpose (see the last bullet below).

The MR wires read-only checks into git push access, project policies, work item policies, and mirroring workers to block content changes and mirror operations while GitHub owns the project. Read-only abilities are now centralized in a single YAML permission group (config/authz/permission_groups/internal/project/import_sync_read_only.yml) instead of scattered inline prevent calls. Two issues found by automated review were fixed: an incorrect score: 0 on the import_sync_read_only policy condition (it does hit the database), and a bug where invalid mirror parameter values bypassed the lock preventing mirror changes during sync.

References

How to set up and validate locally

  1. Enable the github_continuous_import feature flag.
  2. Run the specs for the touched files:
bundle exec rspec ee/spec/services/import/sync/ ee/spec/lib/gitlab/git_access_spec.rb ee/spec/policies/ ee/spec/models/merge_request/ ee/spec/services/projects/update_service_spec.rb ee/spec/workers/

Notes for the reviewer

  • The PoC also blocked changing a project's default branch and running repository cleanup (bfg_object_map) while GitHub owned the project. That's not in this issue's acceptance criteria, so it's left out here on purpose. If that was actually wanted, it looks like a scoping gap rather than an intentional cut — happy to file a follow-up issue.
  • The issue asked us to double check the mirror-guard call sites are actually covered. Confirmed: admin_remote_mirror had to be added to ProjectPolicy's deny list for that one ability check to correctly stand in for guards in RemoteMirrors::{Create,Sync,Update}Service and similar, instead of duplicating a guard in each.
  • Of the 6 GraphQL mutation specs the issue called for, 3 (squash_options/update, branch_rules/update, branch_rules/destroy) extended an existing EE spec file. The other 3 (blobs_remove, text_replace, branch_rules/create) needed a new EE spec file from scratch. Coverage is the same either way.
  • Database reviewer: please take a close look at the mirrors_to_sync scope in ee/app/models/ee/project.rb (updated with a NOT EXISTS subquery against import_sync_repositories) and the new provider_bound scope in ee/app/models/import/sync/repository.rb (filters by authority_state values). These are both new or modified database queries and worth a close review.

Database review

Query: Project.mirrors_to_sync(freeze_at, limit:), defined in ee/app/models/ee/project.rb.

This change adds a NOT EXISTS subquery to exclude projects whose repository sync is provider-owned (e.g. by GitHub) from GitLab's legacy mirroring. The subquery filters import_sync_repositories by project_id and authority_state. Since import_sync_repositories has a unique index on project_id, the database can check for a matching row per project without scanning the whole table. The SQL and execution plan are below. The local dev database has very little data, so the execution times aren't representative of production scale, but the plan confirms the database uses the project_id index rather than a sequential scan.

Full SQL (captured from a local dev database)
SELECT "projects"."id", "projects"."name", "projects"."path", "projects"."description",
       "projects"."created_at", "projects"."updated_at", "projects"."creator_id",
       "projects"."namespace_id", "projects"."last_activity_at", "projects"."import_url",
       "projects"."visibility_level", "projects"."archived", "projects"."avatar",
       "projects"."merge_requests_template", "projects"."star_count",
       "projects"."merge_requests_rebase_enabled", "projects"."import_type",
       "projects"."import_source", "projects"."approvals_before_merge",
       "projects"."reset_approvals_on_push", "projects"."merge_requests_ff_only_enabled",
       "projects"."issues_template", "projects"."mirror", "projects"."mirror_last_update_at",
       "projects"."mirror_last_successful_update_at", "projects"."mirror_user_id",
       "projects"."shared_runners_enabled", "projects"."runners_token",
       "projects"."build_allow_git_fetch", "projects"."build_timeout",
       "projects"."mirror_trigger_builds", "projects"."pending_delete",
       "projects"."public_builds", "projects"."last_repository_check_failed",
       "projects"."last_repository_check_at", "projects"."only_allow_merge_if_pipeline_succeeds",
       "projects"."has_external_issue_tracker", "projects"."repository_storage",
       "projects"."repository_read_only", "projects"."request_access_enabled",
       "projects"."has_external_wiki", "projects"."ci_config_path", "projects"."lfs_enabled",
       "projects"."description_html", "projects"."only_allow_merge_if_all_discussions_are_resolved",
       "projects"."repository_size_limit", "projects"."printing_merge_request_link_enabled",
       "projects"."auto_cancel_pending_pipelines", "projects"."service_desk_enabled",
       "projects"."cached_markdown_version", "projects"."last_repository_updated_at",
       "projects"."disable_overriding_approvers_per_merge_request", "projects"."storage_version",
       "projects"."resolve_outdated_diff_discussions", "projects"."remote_mirror_available_overridden",
       "projects"."only_mirror_protected_branches", "projects"."pull_mirror_available_overridden",
       "projects"."jobs_cache_index", "projects"."external_authorization_classification_label",
       "projects"."mirror_overwrites_diverged_branches", "projects"."pages_https_only",
       "projects"."external_webhook_token", "projects"."packages_enabled",
       "projects"."merge_requests_author_approval", "projects"."pool_repository_id",
       "projects"."runners_token_encrypted", "projects"."bfg_object_map",
       "projects"."detected_repository_languages", "projects"."merge_requests_disable_committers_approval",
       "projects"."require_password_to_approve", "projects"."max_pages_size",
       "projects"."max_artifacts_size", "projects"."pull_mirror_branch_prefix",
       "projects"."remove_source_branch_after_merge", "projects"."marked_for_deletion_at",
       "projects"."marked_for_deletion_by_user_id", "projects"."autoclose_referenced_issues",
       "projects"."suggestion_commit_message", "projects"."project_namespace_id",
       "projects"."hidden", "projects"."organization_id"
FROM "projects"
LEFT OUTER JOIN "namespaces" ON "namespaces"."type" = 'Group'
  AND "namespaces"."id" = "projects"."namespace_id" AND "namespaces"."type" = 'Group'
INNER JOIN project_mirror_data import_state ON import_state.project_id = projects.id
WHERE "projects"."mirror" = TRUE
  AND "projects"."archived" = FALSE
  AND NOT (EXISTS (
    SELECT 1 FROM "namespace_settings"
    WHERE "namespace_settings"."namespace_id" = ANY (namespaces.traversal_ids)
      AND "namespace_settings"."archived" = TRUE
  ))
  AND "projects"."pending_delete" = FALSE
  AND "import_state"."status" NOT IN ('scheduled', 'started')
  AND (import_state.next_execution_timestamp <= '2026-10-06 13:50:18.394575')
  AND (import_state.retry_count <= 14)
  AND NOT (EXISTS (
    SELECT 1 FROM "import_sync_repositories"
    WHERE "import_sync_repositories"."authority_state" IN (0, 2, 3, 4, 5)
      AND "import_sync_repositories"."project_id" = "projects"."id"
  ))
ORDER BY import_state.next_execution_timestamp
LIMIT 1000
EXPLAIN (ANALYZE, BUFFERS) output (local dev database)
Limit  (cost=49.46..49.47 rows=1 width=1391) (actual time=0.050..0.051 rows=0 loops=1)
  Buffers: shared hit=3 read=1
  ->  Sort  (cost=49.46..49.47 rows=1 width=1391) (actual time=0.049..0.050 rows=0 loops=1)
        Sort Key: import_state.next_execution_timestamp
        Sort Method: quicksort  Memory: 25kB
        Buffers: shared hit=3 read=1
        ->  Nested Loop Anti Join  (cost=0.43..49.45 rows=1 width=1391) (actual time=0.023..0.024 rows=0 loops=1)
              Buffers: shared read=1
              ->  Nested Loop  (cost=0.29..39.93 rows=1 width=1391) (actual time=0.023..0.024 rows=0 loops=1)
                    Buffers: shared read=1
                    ->  Nested Loop Anti Join  (cost=0.14..35.33 rows=1 width=1383) (actual time=0.023..0.023 rows=0 loops=1)
                          Join Filter: (namespace_settings.namespace_id = ANY (namespaces.traversal_ids))
                          Buffers: shared read=1
                          ->  Nested Loop Left Join  (cost=0.14..18.75 rows=6 width=1415) (actual time=0.023..0.023 rows=0 loops=1)
                                Join Filter: (namespaces.id = projects.namespace_id)
                                Buffers: shared read=1
                                ->  Seq Scan on projects  (cost=0.00..10.50 rows=6 width=1383) (actual time=0.023..0.023 rows=0 loops=1)
                                      Filter: (mirror AND (NOT archived) AND (NOT pending_delete))
                                      Rows Removed by Filter: 1
                                      Buffers: shared read=1
                                ->  Materialize  (cost=0.14..8.16 rows=1 width=40) (never executed)
                                      ->  Index Scan using index_groups_on_path_and_id on namespaces  (cost=0.14..8.16 rows=1 width=40) (never executed)
                          ->  Materialize  (cost=0.00..11.88 rows=75 width=8) (never executed)
                                ->  Seq Scan on namespace_settings  (cost=0.00..11.50 rows=75 width=8) (never executed)
                                      Filter: archived
                    ->  Index Scan using index_project_mirror_data_on_project_id on project_mirror_data import_state  (cost=0.14..4.17 rows=1 width=16) (never executed)
                          Index Cond: (project_id = projects.id)
                          Filter: (((status)::text <> ALL ('{scheduled,started}'::text[])) AND (next_execution_timestamp <= '2026-10-06 13:50:18.394575'::timestamp without time zone) AND (retry_count <= 14))
              ->  Index Scan using index_import_sync_repositories_on_project_id on import_sync_repositories  (cost=0.15..4.84 rows=1 width=8) (never executed)
                    Index Cond: (project_id = projects.id)
                    Filter: (authority_state = ANY ('{0,2,3,4,5}'::integer[]))
Planning:
  Buffers: shared hit=2237 read=153
Planning Time: 5.108 ms
Execution Time: 0.130 ms
Edited by George Koltsov

Merge request reports

Loading
Loading