Repository navigation
feat: sync secrets to GitLab CI environment scopes - #1044
Merged
Merged
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
Syncs created before environment scopes compared each secret with the variable listed last. For a group key with variables in several scopes and none for all environments, they then updated the key without a scope filter, and GitLab chose the variable, e.g. the synced one moved to another scope by hand. These syncs now failed whenever any of the key's variables differed, and stopped updating the moved variable. Send the same unfiltered update, in the same cases, so they behave as before. A key with a variable in another sync's scope is still reported: GitLab could choose that variable.
Projects with review apps can have thousands of GitLab environments. The picker rendered all of them, and the combobox re-sorts its options each time one is added or removed, so opening and closing the list froze the page (about 11 s and 69 s with 2,000 environments). List at most 50 scopes, with an exact match first, and say how many more match. Typing still searches all environments, so any of them can be picked.
Syncs created before environment scopes followed one rule for every key: compare the secret with the variable listed last, and if it is changed, update the key without a scope filter. For a key with a variable for all environments and an override, they now compared and updated the "*" variable instead. That overwrote "*" where they used to change nothing, e.g. with the override's production value, which then reached every job. Use the old rule for every key again. Projects still fail at such a key, as before, but now sync all other keys, delete nothing and name the key. To sync only "*" and leave overrides alone, create the sync again with the all environments scope.
Headless UI only keeps the selected option active if it is rendered. A scope past the first 50 environments, or a typed scope like review/*, wasn't rendered, so Enter or Tab after reopening the list switched the sync to all environments (*). List the selected scope when there is no query. Also list an exact match with the same case first, so typing production picks production rather than Production.
…eletion perform_gitlab_sync looked up the ID of syncs that only stored a path before handle_sync_event checked the org deletion lock, so a job queued before the lock still called GitLab.
A GitLab host entered as http:// behind a proxy that redirects with 307/308 to https:// on another port worked before this change and then failed. Redirects to another host are still refused.
When a redirect to another host is refused, say where GitLab redirected to (scheme, host and port only), so users know which GitLab host to use in their credentials.
2 of 6 tasks
rohan-chaturvedi
marked this pull request as ready for review
November 2, 2026 12:16
rohan-chaturvedi
self-requested a review
November 2, 2026 12:16
rohan-chaturvedi
approved these changes
Nov 2, 2026
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]', which stopped the whole sync. They now sync all other keys and explain the fix: recreate the sync with a 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. When a key has variables in several scopes, e.g. a per-environment override of a synced key, they compare the secret with the variable listed last, and if it changed, update the key without a scope filter: GitLab refuses that for projects (409) and updates a variable of its choice for groups. GitLab also refuses to delete such a key (409). So they write what they wrote before. Now:*), makes it manage only that scope and leave overrides alone.*.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 any port, keeping the method and body. A redirect to another host fails with an error that names that address (scheme, host and port only), so users know which GitLab host to use. 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.*), it leaves overrides alone.http://tohttps://. If your GitLab URL redirects to another host, use the address named in the sync error as the GitLab host in your credentials. Before, syncs sent the GitLab token, and on some redirects the secrets, to whatever host the redirect named.❓ 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.http://tohttps://on another port; other hosts, other ports on the same scheme, downgrades, credentials and backslash tricks refused, and the error names the address), timeouts, environment and group scope listing, path-only syncs, resolving their ID and host normalisation.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 (not while the org is pending deletion).frontend/tests/utils/gitlabEnvironmentScope.test.ts: scope validation and picker options.frontend/tests/components/GitLabEnvironmentScopePicker.test.tsx: reopening the picker and pressing Enter or Tab keeps a selected scope that isn't among the first 50, or that was typed (e.g.review/*).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: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. The only such case found later is a redirect to another host, see below.*changes no variables, after which scoped syncs can be added.Follow-up commits (
2b5a261,45cb501,1ef2237), checked againstmain's sync code (v2.77.2) and self-hosted GitLab CE 19.4:main's rule for every key with variables in several scopes, for projects and groups. Differential test ofmain's and this branch's sync code on about 39,000 random project and group states: the same pass or fail in every state, no extra deletes, and the same variables wherevermainpassed. Side by side against GitLab: keys with a*variable and an override, and keys in several scopes without one, in each order and state, end with the same variables on both.After merging
main(v2.78): follow-up commitsf378dd9,7637705,16dd5fcand4acadd0, checked against self-hosted GitLab CE 19.4.1 with a GitLab Runner:main's code (v2.78.1 images, same code asmain): 10 syncs created throughmain's UI and run onmain, then run on this branch with the same database. Nothing changed in GitLab, and every sync gave the same result, except the redirect cases below.mainkept. Two differences, by design: wheremainstops at a key in several scopes, this branch still syncs the other keys; when a delete is ambiguous, it deletes nothing, whilemaindeleted the keys it reached first.http://…:8080behind a TLS proxy that redirects (308) tohttps://…:8443syncs, as onmain, also through the UI. Redirects to another host fail, and the error names that host. Onmain, a 301 redirect to another host reported success without creating variables.*,review/*, andproductionmasked and protected on a project;*andproductionon a group): 10 pipeline jobs got exactly the expected values (200 values checked). Edits in Phase changed only the matching scope, and re-runs changed nothing.review/*, reopening the list and pressing Tab or Enter keeps the chosen scope, and the create request sends it (Chromium). Before, it switched to*.main's new lock): the new GitLab queries and sync triggers are refused, and GitLab sync jobs pause without calling GitLab, including path-only syncs.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.trigger_sync_tasksqueues the job before it creates the sync event. A job that starts at once finds no event, fails at the end and leaves the sync "in progress" (seen once in QA; affects all providers).✨ 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...