Conversation
GitLab CI/CD variables can be scoped to an environment, and the same key can exist once per scope. GitLab syncs can now target an environment scope, like GitHub Actions syncs can target a GitHub environment. - Add an optional environment scope to GitLab syncs. Pick one of the project's GitLab environments or type any scope GitLab accepts, including wildcards like review/*. New syncs default to all environments (*). - A sync with a scope only creates, updates and deletes variables in that scope. Updates and deletes select the scope explicitly, so keys that exist in several scopes no longer fail with "There are multiple variables with provided parameters". - Syncs created before this change have no scope and keep syncing to all environments. As before, they also update and delete a variable that was moved to another scope in GitLab, as long as its key has no variable for all environments and the scope doesn't belong to another sync. To add scoped syncs to the same project or group in an App, such a sync must be recreated with a scope first. - GitLab tiers without scoped group variables silently create the variable for all environments. Group syncs with a scope check this with a throwaway variable before writing anything, and every create and update verifies the scope GitLab used, so a scoped secret is never left exposed to every environment. - Reject a second sync in the same App to the same GitLab project or group and environment scope, as the two would overwrite each other's variables. - GitLab API requests time out and no longer follow redirects, which could forward the token or a secret to another host. Tokens are trimmed and never included in errors. - Add a gitlabEnvironments query to list a project's environments, and show the scope on the sync card.
- Keep a typed environment scope when the picker closes without a pick, so the scope that's shown is the one that's used instead of silently falling back to all environments. Escape discards it. - Pressing Enter in the form no longer goes back to step 1: the Back button was the form's default submit button. - Suggest the environment scopes already used by a group's variables for group syncs, as GitLab does (groups have no environments). Only scope names are fetched, and older GitLab versions get no suggestions.
- Detect syncs created before July 2024, which only stored the project or group path, when checking for conflicting syncs. A scoped sync could be created next to one, and the older sync then deleted its variables. - Check for conflicting syncs when a sync's credentials change, or a credential's GitLab host changes, so two syncs can't end up writing the same project or group and scope. Staying on the same GitLab instance, e.g. to use a new token, is never blocked. - Group variable PUTs fall back to another variable with the same key when the targeted one was deleted after it was listed, moving it into the sync's scope. Recreate variables of other scopes taken this way. - Syncs created before environment scopes now report keys they can't update or delete because they exist in several scopes, instead of skipping deletions silently, after making all other changes. - Follow redirects on the same GitLab host, e.g. http to https, as requests used to, so syncs using such hosts keep working. Redirects to other hosts are still refused, with a clearer error. - An invalid typed environment scope is kept and blocks creating the sync, instead of falling back to the previous scope. Picking another group resets the scope, as picking another project does.
…https hosts - Syncs created before environment scopes stopped at keys GitLab refused to change because they exist in several scopes, so variables were never deleted past them. Delete nothing while such a key exists, and name it in the error, so the upgrade can't remove variables pipelines rely on. New and changed secrets are still synced. - Treat http:// and https:// (and default ports) of the same host as the same GitLab instance when checking for conflicting syncs.
- Decide whether a redirect stays on the GitLab host with the URL parser requests connects with: urllib.parse and urllib3 disagree on backslashes, which let a redirect pass the check and send the token and secrets elsewhere. Redirects with credentials, to other ports (except http to https on 443) or downgrading to http are refused, and each hop is validated in cloud. In cloud, GitLab hosts the two parsers read differently are refused too. - Before updating a group variable, check that it still exists, and create it if not: GitLab's group PUT would otherwise update another variable with the key. This replaces recreating variables afterwards, which could bring back variables deleted in the meantime. - Syncs created before environment scopes delete nothing exactly when they used to stop: a project key whose last-listed variable differs, or a removed key with variables in several scopes. Group updates never stopped, so group syncs keep deleting next to overrides. - Store the ID of syncs that only stored a project or group path on their next run, so they're recognised after a rename. - Syncs created before environment scopes conflict with scoped syncs to the same project or group in any App of the organisation. - Keep a scope chosen before the first project or group is picked. - Don't name syncs of Apps a member can't access in credential errors.
nimish-ks
marked this pull request as draft
October 4, 2026 10:05
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🔍 Overview
Closes #1043.
GitLab CI/CD variables can be limited to an environment with an environment scope, and the same key can exist once per scope. Until now, GitLab syncs always wrote to the default
*scope. There was no way to sync e.g. Staging and Production secrets into the matching GitLab environments of one project, as GitHub Actions syncs can with GitHub environments.Existing syncs also broke as soon as a synced key existed in more than one scope: updates failed with
There are multiple variables with provided parameters. Please use 'filter[environment_scope]'.💡 Proposed Changes
Environment scope on GitLab syncs
*), and any scope GitLab accepts can be typed, including wildcards likereview/*.gitlabEnvironments(credentialId, projectId)query. It is paginated and uses the same credential permission check as the sibling GitLab queries. Projects with Environments disabled return an empty list instead of an error.gitlabGroupEnvironmentScopes(credentialId, groupPath)query, using GitLab's GraphQLgroup.environmentScopes. It only fetches scope names, never values, and returns an empty list on GitLab versions without that field.createGitlabCiSynctakes an optionalenvironmentScope, validated against GitLab's own rules (charset, 255 chars) and stored in the sync options.phase/backend (production).type, so it was the form's default submit button.Scope-aware sync
filter[environment_scope], so keys that exist in several scopes no longer break the sync.http://andhttps://URLs of a host count as the same instance. Errors only name syncs of Apps the member can access.Existing syncs (verified with an upgrade test, see Testing)
*).*variable and the scope isn't managed by another sync to the same project or group.*variable updates that variable and leaves overrides alone.*), it updates none, and reports it wherever the old sync failed or changed one of them.*.Safety
environment_scopeand create the variable for all environments.PHASE_SCOPE_CHECK_*variable holding a random value. If GitLab dropped the scope, they abort with a clear error, so no secret is ever written there.PUTfalls back to any variable with the same key when the scope filter matches nothing, e.g. when a concurrent run deleted the variable after it was listed, and GitLab ≤ 16.7 ignores the filter on groupPUTentirely.GETdoesn't fall back) and create it again if not. Otherwise GitLab would update another variable with the key, e.g. a manual one in another scope.http://tohttps://on 443, keeping the method and body. The host is read with the parserrequestsconnects with, redirects with credentials are refused, and in cloud each hop goes through the SSRF check again. In cloud, GitLab hosts thaturllib.parseandrequestsread differently (e.g. with a backslash) are refused. Before,requestsfollowed any redirect, which:Private-Tokenheader (and, on 307/308, the secret payload) to wherever the GitLab host redirected, bypassing the cloud SSRF check on the configured host;http://→https://redirect intoGETs: the sync reported success without ever creating a variable. GitLab also redirects the old path of a renamed project, so path-only syncs to renamed projects had the same problem.requestsechoed the header value into sync logs and GraphQL errors.🖼️ Screenshots or Demo
Screenshots of the scope picker, the configured sync and the sync card are in the docs PR, under
public/assets/images/platform-integrations/gitlab/: https://github.com/phasehq/docs/pull/261/files📝 Release Notes
production,staging,review/*), so each Phase Environment can be synced to the matching GitLab environment of the same project or group.http://host that redirects tohttps://(or the old path of a renamed project) reported success without creating new variables.❓ Open Questions
edge/svcstagingreplaced all of the first App'sstagingvariables, and the two syncs would keep overwriting each other. Should GitLab (or all providers) check across the organisation?🧪 Testing
Automated
backend/tests/utils/syncing/test_gitlab.py: sync behaviour against an in-memory fake of the GitLab variables API.PUTfallback (and thatGET/DELETEdon't fall back) and Free-tier scope dropping.backend/tests/graphene/mutations/test_gitlab_sync_mutations.py: scope validation and conflict detection on create, credential switch and credential host change (legacy and path-only syncs, other Apps, other GitLab instances, token rotation, syncs without credentials, errors for Apps the member can't access).backend/tests/tasks/test_syncing.py: dispatch for scoped vs. legacy syncs, and storing the ID of path-only syncs.frontend/tests/utils/gitlabEnvironmentScope.test.ts: scope validation and picker options.Upgrade test: syncs created with
main, then run with this branch (self-hosted GitLab CE 19.4)maincreates (confirmed againstmain's own mutation), each on its own project or group, covering:*, file type,raw: false, comments);productionby hand, and per-environment overrides on a project and a group;main's backend, worker and frontend, switched the same database to this branch, and re-ran every sync with nothing changed. No sync that passed onmainfailed, nothingmainkept was deleted, and the only changes were the fixes below. The path-only sync stored its ID.mainand on this branch, and diffed every variable and sync result after each step. Everything is identical except:mainfailed with the 409 on every run once the override existed, so*stayed stale. Now*is updated and the override kept.mainoverwrote the manualproductionoverride with the default value and left*stale. Now*is updated and the override kept.mainreported "synchronized" on every run but never created a variable. Now they're created, and unchanged runs report "No changes needed".mainand fails here, at any step.*changes no variables, after which scoped syncs can be added.Manual QA against self-hosted GitLab CE 19.4 and GitLab EE 19.4 on the Free and Ultimate tiers, with a GitLab Runner executing real pipelines.
Sync behaviour
review/*, Staging →stagingand Production →production, plus an existing unscoped sync, into one project. Checked every variable's value, description (comment), masked/protected/raw flags and scope through the API.production,staging,development,review/feature-loginand no environment. Each job received exactly the expected values: scoped values override*,review/*matchesreview/feature-login, and$in values is not expanded.Several syncs on one project and group with existing variables (scripted against real GitLab, checked through its API and 3 pipelines):
filevariable keeps its type;/api→development) on one project, and*andstaginggroup syncs on its group. Pipelines confirm that project variables beat group variables, and thatreview/feature-loginbeatsreview/*.*variable moved toproductionby hand: the*sync recreates it, and theproductionsync deletes the moved copy.*). The UI shows the error and keeps the form.PHASE_SCOPE_CHECK_*variable left behind.GitLab EE Ultimate
productionmasked and protected,staging,*,review/*) on a group, a scoped sync on a nested subgroup and a project sync below it, with pre-existing group variables in and outside the synced scopes.PUTrace, reproduced against real GitLab by deleting the variable after the listing: the sync created it again, and the other scopes' variables (including a manual one) were untouched.*group syncs keep working. Scoped group syncs fail with the tier explanation without writing anything, and catch up once the license is back.Form state (31 checks, Playwright, payload captured from the request):
Redirects: besides the unit tests, 18,468
Locationvalues (userinfo and backslash tricks, percent-encoding, IDNA, IPv6 and zone IDs, control characters, scheme-relative andurljoinoddities) against 8 starting URLs: every redirect that is followed connects to the same host and port as checked, and a backslash proof of concept that reached another server before the fix is refused.yarn build,tsc, ESLint andyarn testpass. The backend suite passes excepttest_file_read_permission_error, an unrelated test that relies onchmod 000and so fails whenever tests run as root.🎯 Reviewer Focus
backend/api/utils/syncing/gitlab/main.py:sync_gitlab_secrets,gitlab_sync_conflict,same_gitlab_resource,get_environment_scopes_of_other_syncs,gitlab_requestand_connection_target.backend/backend/graphene/mutations/syncing.py:CreateGitLabCISync,check_gitlab_host_change(used byUpdateSyncAuthenticationandUpdateProviderCredentials).backend/api/tasks/syncing.py:perform_gitlab_sync(storing the ID of path-only syncs).frontend/components/syncing/GitLab/.➕ Additional Context
Docs: phasehq/docs#261
Known limitations of this change:
*variable is removed from Phase and the key is later added back, the remaining override is treated as moved and gets updated.Pre-existing issues noticed while working on this, not changed here:
validate_url_is_saferesolves the host once andrequestsresolves it again (DNS rebinding). It also parses URLs differently fromrequests(e.g. a backslash before@), so a URL can pass it and connect elsewhere. GitLab now refuses such hosts itself; the OIDC issuer and AWS STS endpoint checks still use it as is.min_access_level=30(Developer), but managing CI/CD variables needs Maintainer, so some listed projects can't be synced.raw: falseand the same value, keeps variable expansion enabled.{"message":{"value":["is invalid"]}}, which doesn't say why.timeouton requests applies per socket read, not to the whole request, and the project/group listings have no page cap.cancel_sync_tasksremoves queued jobs but doesn't stop running ones.UpdateSyncAuthenticationalso accepts a credential of another provider.type, so Enter in their text fields goes back to step 1.✨ How to Test the Changes Locally
docker run -p 8929:8929 -e GITLAB_OMNIBUS_CONFIG="external_url 'http://localhost:8929'" gitlab/gitlab-ce. Create a project with a few environments (production,staging,review/feature-1) and a personal access token with theapiscope.http://localhost:8929, enable SSE for an App and create a GitLab CI sync. Pick an environment scope in the new picker, or typereview/*.💚 Did You...