From 0a47f475abdb58d198d250d68b3322a67c161224 Mon Sep 17 00:00:00 2001 From: Douglas Coburn Date: Sat, 23 Aug 2025 07:03:15 -0700 Subject: [PATCH 1/2] Removing test files --- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2f5c9e7..6bf7d9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.2.0" +version = "2.2.1" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 9309f87..ee1698c 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.2.0' +__version__ = '2.2.1' From 9d25a7666c0678c6dae553325e611b0388f828bc Mon Sep 17 00:00:00 2001 From: Douglas Coburn Date: Sat, 23 Aug 2025 07:12:19 -0700 Subject: [PATCH 2/2] Adding support for both gitlab token styles --- README.md | 74 ++++++++++++ pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/scm/client.py | 47 +++++++- socketsecurity/core/scm/gitlab.py | 120 +++++++++++++++++-- tests/unit/test_gitlab_auth.py | 116 +++++++++++++++++++ tests/unit/test_gitlab_auth_fallback.py | 148 ++++++++++++++++++++++++ workflows/gitlab-ci.yml | 3 + 8 files changed, 500 insertions(+), 12 deletions(-) create mode 100644 tests/unit/test_gitlab_auth.py create mode 100644 tests/unit/test_gitlab_auth_fallback.py diff --git a/README.md b/README.md index 6f9ca2b..f0cf94b 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,74 @@ The CLI uses intelligent default branch detection with the following priority: Both `--default-branch` and `--pending-head` parameters are automatically synchronized to ensure consistent behavior. +## GitLab Token Configuration + +The CLI supports GitLab integration with automatic authentication pattern detection for different token types. + +### Supported Token Types + +GitLab API supports two authentication methods, and the CLI automatically detects which one to use: + +1. **Bearer Token Authentication** (`Authorization: Bearer `) + - GitLab CI Job Tokens (`$CI_JOB_TOKEN`) + - Personal Access Tokens with `glpat-` prefix + - OAuth 2.0 tokens (long alphanumeric tokens) + +2. **Private Token Authentication** (`PRIVATE-TOKEN: `) + - Legacy personal access tokens + - Custom tokens that don't match Bearer patterns + +### Token Detection Logic + +The CLI automatically determines the authentication method using this logic: + +``` +if token == $CI_JOB_TOKEN: + use Bearer authentication +elif token starts with "glpat-": + use Bearer authentication +elif token is long (>40 chars) and alphanumeric: + use Bearer authentication +else: + use PRIVATE-TOKEN authentication +``` + +### Automatic Fallback + +If the initial authentication method fails with a 401 error, the CLI automatically retries with the alternative method: + +- **Bearer → PRIVATE-TOKEN**: If Bearer authentication fails, retry with PRIVATE-TOKEN +- **PRIVATE-TOKEN → Bearer**: If PRIVATE-TOKEN fails, retry with Bearer authentication + +This ensures maximum compatibility across different GitLab configurations and token types. + +### Environment Variables + +| Variable | Description | Example | +|:---------|:------------|:--------| +| `GITLAB_TOKEN` | GitLab API token (required for GitLab integration) | `glpat-xxxxxxxxxxxxxxxxxxxx` | +| `CI_JOB_TOKEN` | GitLab CI job token (automatically used in GitLab CI) | Automatically provided by GitLab CI | + +### Usage Examples + +**GitLab CI with job token (recommended):** +```yaml +variables: + GITLAB_TOKEN: $CI_JOB_TOKEN +``` + +**GitLab CI with personal access token:** +```yaml +variables: + GITLAB_TOKEN: $GITLAB_PERSONAL_ACCESS_TOKEN # Set in GitLab project/group variables +``` + +**Local development:** +```bash +export GITLAB_TOKEN="glpat-your-personal-access-token" +socketcli --integration gitlab --repo owner/repo --pr-number 123 +``` + ### Scan Behavior The CLI determines scanning behavior intelligently: @@ -340,4 +408,10 @@ Implementation targets: ### Environment Variables +#### Core Configuration +- `SOCKET_SECURITY_API_KEY`: Socket Security API token (alternative to --api-token parameter) - `SOCKET_SDK_PATH`: Path to local socket-sdk-python repository (default: ../socket-sdk-python) + +#### GitLab Integration +- `GITLAB_TOKEN`: GitLab API token for GitLab integration (supports both Bearer and PRIVATE-TOKEN authentication) +- `CI_JOB_TOKEN`: GitLab CI job token (automatically provided in GitLab CI environments) diff --git a/pyproject.toml b/pyproject.toml index 6bf7d9c..e4c54e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.2.1" +version = "2.2.2" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index ee1698c..c0d192a 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.2.1' +__version__ = '2.2.2' diff --git a/socketsecurity/core/scm/client.py b/socketsecurity/core/scm/client.py index e5bbb73..1033613 100644 --- a/socketsecurity/core/scm/client.py +++ b/socketsecurity/core/scm/client.py @@ -34,8 +34,51 @@ def get_headers(self) -> Dict: class GitlabClient(ScmClient): def get_headers(self) -> Dict: - return { - 'Authorization': f"Bearer {self.token}", + """ + Determine the appropriate authentication headers for GitLab API. + Uses the same logic as GitlabConfig._get_auth_headers() + """ + return self._get_gitlab_auth_headers(self.token) + + @staticmethod + def _get_gitlab_auth_headers(token: str) -> dict: + """ + Determine the appropriate authentication headers for GitLab API. + + GitLab supports two authentication patterns: + 1. Bearer token (OAuth 2.0 tokens, personal access tokens with api scope) + 2. Private token (personal access tokens) + """ + import os + + base_headers = { 'User-Agent': 'SocketPythonScript/0.0.1', "accept": "application/json" } + + # Check if this is a GitLab CI job token + if token == os.getenv('CI_JOB_TOKEN'): + return { + **base_headers, + 'Authorization': f"Bearer {token}" + } + + # Check for personal access token pattern + if token.startswith('glpat-'): + return { + **base_headers, + 'Authorization': f"Bearer {token}" + } + + # Check for OAuth token pattern (typically longer and alphanumeric) + if len(token) > 40 and token.isalnum(): + return { + **base_headers, + 'Authorization': f"Bearer {token}" + } + + # Default to PRIVATE-TOKEN for other token types + return { + **base_headers, + 'PRIVATE-TOKEN': f"{token}" + } diff --git a/socketsecurity/core/scm/gitlab.py b/socketsecurity/core/scm/gitlab.py index 24b1df3..b5f46b7 100644 --- a/socketsecurity/core/scm/gitlab.py +++ b/socketsecurity/core/scm/gitlab.py @@ -42,6 +42,9 @@ def from_env(cls) -> 'GitlabConfig': mr_source_branch = os.getenv('CI_MERGE_REQUEST_SOURCE_BRANCH_NAME') default_branch = os.getenv('CI_DEFAULT_BRANCH', '') + # Determine which authentication pattern to use + headers = cls._get_auth_headers(token) + return cls( commit_sha=os.getenv('CI_COMMIT_SHA', ''), api_url=os.getenv('CI_API_V4_URL', ''), @@ -57,18 +60,119 @@ def from_env(cls) -> 'GitlabConfig': token=token, repository=project_name, is_default_branch=(mr_source_branch == default_branch if mr_source_branch else False), - headers={ - 'Authorization': f"Bearer {token}", - 'User-Agent': 'SocketPythonScript/0.0.1', - "accept": "application/json" - } + headers=headers ) + @staticmethod + def _get_auth_headers(token: str) -> dict: + """ + Determine the appropriate authentication headers for GitLab API. + + GitLab supports two authentication patterns: + 1. Bearer token (OAuth 2.0 tokens, personal access tokens with api scope) + 2. Private token (personal access tokens) + + Logic for token type determination: + - CI_JOB_TOKEN: Always use Bearer (GitLab CI job token) + - Tokens starting with 'glpat-': Personal access tokens, try Bearer first + - OAuth tokens: Use Bearer + - Other tokens: Use PRIVATE-TOKEN as fallback + """ + base_headers = { + 'User-Agent': 'SocketPythonScript/0.0.1', + "accept": "application/json" + } + + # Check if this is a GitLab CI job token + if token == os.getenv('CI_JOB_TOKEN'): + log.debug("Using Bearer authentication for GitLab CI job token") + return { + **base_headers, + 'Authorization': f"Bearer {token}" + } + + # Check for personal access token pattern + if token.startswith('glpat-'): + log.debug("Using Bearer authentication for GitLab personal access token") + return { + **base_headers, + 'Authorization': f"Bearer {token}" + } + + # Check for OAuth token pattern (typically longer and alphanumeric) + if len(token) > 40 and token.isalnum(): + log.debug("Using Bearer authentication for potential OAuth token") + return { + **base_headers, + 'Authorization': f"Bearer {token}" + } + + # Default to PRIVATE-TOKEN for other token types + log.debug("Using PRIVATE-TOKEN authentication for GitLab token") + return { + **base_headers, + 'PRIVATE-TOKEN': f"{token}" + } + class Gitlab: def __init__(self, client: CliClient, config: Optional[GitlabConfig] = None): self.config = config or GitlabConfig.from_env() self.client = client + def _request_with_fallback(self, **kwargs): + """ + Make a request with automatic fallback between Bearer and PRIVATE-TOKEN authentication. + This provides robustness when the initial token type detection is incorrect. + """ + try: + # Try the initial request with the configured headers + return self.client.request(**kwargs) + except Exception as e: + # Check if this is an authentication error (401) + if hasattr(e, 'response') and e.response and e.response.status_code == 401: + log.debug(f"Authentication failed with initial headers, trying fallback method") + + # Determine the fallback headers + original_headers = kwargs.get('headers', self.config.headers) + fallback_headers = self._get_fallback_headers(original_headers) + + if fallback_headers and fallback_headers != original_headers: + log.debug("Retrying request with fallback authentication method") + kwargs['headers'] = fallback_headers + return self.client.request(**kwargs) + + # Re-raise the original exception if it's not an auth error or fallback failed + raise + + def _get_fallback_headers(self, original_headers: dict) -> dict: + """ + Generate fallback authentication headers. + If using Bearer, fallback to PRIVATE-TOKEN and vice versa. + """ + base_headers = { + 'User-Agent': 'SocketPythonScript/0.0.1', + "accept": "application/json" + } + + # If currently using Bearer, try PRIVATE-TOKEN + if 'Authorization' in original_headers and 'Bearer' in original_headers['Authorization']: + log.debug("Falling back from Bearer to PRIVATE-TOKEN authentication") + return { + **base_headers, + 'PRIVATE-TOKEN': f"{self.config.token}" + } + + # If currently using PRIVATE-TOKEN, try Bearer + elif 'PRIVATE-TOKEN' in original_headers: + log.debug("Falling back from PRIVATE-TOKEN to Bearer authentication") + return { + **base_headers, + 'Authorization': f"Bearer {self.config.token}" + } + + # No fallback available + return None + def check_event_type(self) -> str: pipeline_source = self.config.pipeline_source.lower() if pipeline_source in ["web", 'merge_request_event', "push", "api"]: @@ -84,7 +188,7 @@ def check_event_type(self) -> str: def post_comment(self, body: str) -> None: path = f"projects/{self.config.mr_project_id}/merge_requests/{self.config.mr_iid}/notes" payload = {"body": body} - self.client.request( + self._request_with_fallback( path=path, payload=payload, method="POST", @@ -95,7 +199,7 @@ def post_comment(self, body: str) -> None: def update_comment(self, body: str, comment_id: str) -> None: path = f"projects/{self.config.mr_project_id}/merge_requests/{self.config.mr_iid}/notes/{comment_id}" payload = {"body": body} - self.client.request( + self._request_with_fallback( path=path, payload=payload, method="PUT", @@ -106,7 +210,7 @@ def update_comment(self, body: str, comment_id: str) -> None: def get_comments_for_pr(self) -> dict: log.debug(f"Getting Gitlab comments for Repo {self.config.repository} for PR {self.config.mr_iid}") path = f"projects/{self.config.mr_project_id}/merge_requests/{self.config.mr_iid}/notes" - response = self.client.request( + response = self._request_with_fallback( path=path, headers=self.config.headers, base_url=self.config.api_url diff --git a/tests/unit/test_gitlab_auth.py b/tests/unit/test_gitlab_auth.py new file mode 100644 index 0000000..be34224 --- /dev/null +++ b/tests/unit/test_gitlab_auth.py @@ -0,0 +1,116 @@ +"""Tests for GitLab authentication patterns""" +import os +import pytest +from unittest.mock import patch, MagicMock + +from socketsecurity.core.scm.gitlab import GitlabConfig + + +class TestGitlabAuthHeaders: + """Test GitLab authentication header generation""" + + def test_ci_job_token_uses_bearer(self): + """CI_JOB_TOKEN should always use Bearer authentication""" + with patch.dict(os.environ, {'CI_JOB_TOKEN': 'ci-job-token-123'}): + headers = GitlabConfig._get_auth_headers('ci-job-token-123') + assert 'Authorization' in headers + assert headers['Authorization'] == 'Bearer ci-job-token-123' + assert 'PRIVATE-TOKEN' not in headers + + def test_personal_access_token_uses_bearer(self): + """Personal access tokens (glpat-*) should use Bearer authentication""" + token = 'glpat-xxxxxxxxxxxxxxxxxxxx' + headers = GitlabConfig._get_auth_headers(token) + assert 'Authorization' in headers + assert headers['Authorization'] == f'Bearer {token}' + assert 'PRIVATE-TOKEN' not in headers + + def test_oauth_token_uses_bearer(self): + """Long alphanumeric tokens (OAuth) should use Bearer authentication""" + token = 'a' * 50 # 50 character alphanumeric token + headers = GitlabConfig._get_auth_headers(token) + assert 'Authorization' in headers + assert headers['Authorization'] == f'Bearer {token}' + assert 'PRIVATE-TOKEN' not in headers + + def test_short_token_uses_private_token(self): + """Short tokens should use PRIVATE-TOKEN authentication""" + token = 'short-token-123' + headers = GitlabConfig._get_auth_headers(token) + assert 'PRIVATE-TOKEN' in headers + assert headers['PRIVATE-TOKEN'] == token + assert 'Authorization' not in headers + + def test_mixed_alphanumeric_token_uses_private_token(self): + """Tokens with non-alphanumeric characters should use PRIVATE-TOKEN""" + token = 'token-with-dashes-and_underscores' + headers = GitlabConfig._get_auth_headers(token) + assert 'PRIVATE-TOKEN' in headers + assert headers['PRIVATE-TOKEN'] == token + assert 'Authorization' not in headers + + def test_all_headers_include_base_headers(self): + """All authentication patterns should include base headers""" + test_tokens = [ + 'glpat-xxxxxxxxxxxxxxxxxxxx', # Bearer + 'short-token' # PRIVATE-TOKEN + ] + + for token in test_tokens: + headers = GitlabConfig._get_auth_headers(token) + assert headers['User-Agent'] == 'SocketPythonScript/0.0.1' + assert headers['accept'] == 'application/json' + + @patch.dict(os.environ, {'CI_JOB_TOKEN': 'ci-token-123'}) + def test_ci_job_token_detection_priority(self): + """CI_JOB_TOKEN should be detected even if token doesn't match CI_JOB_TOKEN value""" + # This tests the case where GITLAB_TOKEN != CI_JOB_TOKEN + headers = GitlabConfig._get_auth_headers('different-token') + # Should not use Bearer since token doesn't match CI_JOB_TOKEN + assert 'PRIVATE-TOKEN' in headers + assert headers['PRIVATE-TOKEN'] == 'different-token' + + +class TestGitlabConfigFromEnv: + """Test GitlabConfig.from_env() method""" + + @patch.dict(os.environ, { + 'GITLAB_TOKEN': 'glpat-test-token', + 'CI_PROJECT_NAME': 'test-project', + 'CI_API_V4_URL': 'https://gitlab.example.com/api/v4', + 'CI_COMMIT_SHA': 'abc123', + 'CI_PROJECT_DIR': '/builds/test', + 'CI_PIPELINE_SOURCE': 'merge_request_event' + }) + def test_from_env_creates_config_with_correct_headers(self): + """from_env should create config with appropriate auth headers""" + config = GitlabConfig.from_env() + + # Should use Bearer for glpat- token + assert 'Authorization' in config.headers + assert config.headers['Authorization'] == 'Bearer glpat-test-token' + assert 'PRIVATE-TOKEN' not in config.headers + assert config.token == 'glpat-test-token' + + @patch.dict(os.environ, { + 'GITLAB_TOKEN': 'custom-token', + 'CI_PROJECT_NAME': 'test-project' + }, clear=True) + def test_from_env_with_private_token(self): + """from_env should use PRIVATE-TOKEN for non-standard tokens""" + config = GitlabConfig.from_env() + + # Should use PRIVATE-TOKEN for custom token + assert 'PRIVATE-TOKEN' in config.headers + assert config.headers['PRIVATE-TOKEN'] == 'custom-token' + assert 'Authorization' not in config.headers + + def test_from_env_missing_token_exits(self): + """from_env should exit when GITLAB_TOKEN is missing""" + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(SystemExit): + GitlabConfig.from_env() + + +if __name__ == '__main__': + pytest.main([__file__]) diff --git a/tests/unit/test_gitlab_auth_fallback.py b/tests/unit/test_gitlab_auth_fallback.py new file mode 100644 index 0000000..e9e9b0c --- /dev/null +++ b/tests/unit/test_gitlab_auth_fallback.py @@ -0,0 +1,148 @@ +"""Integration test demonstrating GitLab authentication fallback""" +import os +from unittest.mock import patch, MagicMock +import pytest + +from socketsecurity.core.scm.gitlab import Gitlab, GitlabConfig +from socketsecurity.socketcli import CliClient + + +class TestGitlabAuthFallback: + """Test GitLab authentication fallback mechanism""" + + @patch.dict(os.environ, { + 'GITLAB_TOKEN': 'test-token', + 'CI_PROJECT_NAME': 'test-project', + 'CI_API_V4_URL': 'https://gitlab.example.com/api/v4', + 'CI_MERGE_REQUEST_IID': '123', + 'CI_MERGE_REQUEST_PROJECT_ID': '456' + }) + def test_fallback_from_private_token_to_bearer(self): + """Test fallback from PRIVATE-TOKEN to Bearer authentication""" + # Create a mock client that simulates auth failure then success + mock_client = MagicMock(spec=CliClient) + + # First call (with PRIVATE-TOKEN) fails with 401 + auth_error = Exception() + auth_error.response = MagicMock() + auth_error.response.status_code = 401 + + # Second call (with Bearer) succeeds + success_response = {'notes': []} + + mock_client.request.side_effect = [auth_error, success_response] + + # Create GitLab instance with mock client + gitlab = Gitlab(client=mock_client) + + # This should trigger the fallback mechanism + result = gitlab.get_comments_for_pr() + + # Verify two requests were made + assert mock_client.request.call_count == 2 + + # First call should use PRIVATE-TOKEN (default for 'test-token') + first_call_headers = mock_client.request.call_args_list[0][1]['headers'] + assert 'PRIVATE-TOKEN' in first_call_headers + assert first_call_headers['PRIVATE-TOKEN'] == 'test-token' + + # Second call should use Bearer (fallback) + second_call_headers = mock_client.request.call_args_list[1][1]['headers'] + assert 'Authorization' in second_call_headers + assert second_call_headers['Authorization'] == 'Bearer test-token' + + @patch.dict(os.environ, { + 'GITLAB_TOKEN': 'glpat-test-token', + 'CI_PROJECT_NAME': 'test-project', + 'CI_API_V4_URL': 'https://gitlab.example.com/api/v4', + 'CI_MERGE_REQUEST_IID': '123', + 'CI_MERGE_REQUEST_PROJECT_ID': '456' + }) + def test_fallback_from_bearer_to_private_token(self): + """Test fallback from Bearer to PRIVATE-TOKEN authentication""" + # Create a mock client that simulates auth failure then success + mock_client = MagicMock(spec=CliClient) + + # First call (with Bearer) fails with 401 + auth_error = Exception() + auth_error.response = MagicMock() + auth_error.response.status_code = 401 + + # Second call (with PRIVATE-TOKEN) succeeds + success_response = {'notes': []} + + mock_client.request.side_effect = [auth_error, success_response] + + # Create GitLab instance with mock client + gitlab = Gitlab(client=mock_client) + + # This should trigger the fallback mechanism + result = gitlab.get_comments_for_pr() + + # Verify two requests were made + assert mock_client.request.call_count == 2 + + # First call should use Bearer (default for 'glpat-' token) + first_call_headers = mock_client.request.call_args_list[0][1]['headers'] + assert 'Authorization' in first_call_headers + assert first_call_headers['Authorization'] == 'Bearer glpat-test-token' + + # Second call should use PRIVATE-TOKEN (fallback) + second_call_headers = mock_client.request.call_args_list[1][1]['headers'] + assert 'PRIVATE-TOKEN' in second_call_headers + assert second_call_headers['PRIVATE-TOKEN'] == 'glpat-test-token' + + @patch.dict(os.environ, { + 'GITLAB_TOKEN': 'test-token', + 'CI_PROJECT_NAME': 'test-project', + 'CI_API_V4_URL': 'https://gitlab.example.com/api/v4', + 'CI_MERGE_REQUEST_IID': '123', + 'CI_MERGE_REQUEST_PROJECT_ID': '456' + }) + def test_non_auth_error_not_retried(self): + """Test that non-authentication errors are not retried""" + # Create a mock client that simulates a non-auth error + mock_client = MagicMock(spec=CliClient) + + # Simulate a 500 error (not auth-related) + server_error = Exception() + server_error.response = MagicMock() + server_error.response.status_code = 500 + + mock_client.request.side_effect = server_error + + # Create GitLab instance with mock client + gitlab = Gitlab(client=mock_client) + + # This should NOT trigger the fallback mechanism + with pytest.raises(Exception): + gitlab.get_comments_for_pr() + + # Verify only one request was made (no retry) + assert mock_client.request.call_count == 1 + + @patch.dict(os.environ, { + 'GITLAB_TOKEN': 'test-token', + 'CI_PROJECT_NAME': 'test-project', + 'CI_API_V4_URL': 'https://gitlab.example.com/api/v4', + 'CI_MERGE_REQUEST_IID': '123', + 'CI_MERGE_REQUEST_PROJECT_ID': '456' + }) + def test_successful_first_attempt_no_fallback(self): + """Test that successful requests don't trigger fallback""" + # Create a mock client that succeeds on first try + mock_client = MagicMock(spec=CliClient) + mock_client.request.return_value = {'notes': []} + + # Create GitLab instance with mock client + gitlab = Gitlab(client=mock_client) + + # This should succeed on first try + result = gitlab.get_comments_for_pr() + + # Verify only one request was made + assert mock_client.request.call_count == 1 + + +if __name__ == '__main__': + pytest.main([__file__]) diff --git a/workflows/gitlab-ci.yml b/workflows/gitlab-ci.yml index 4e44580..59ea864 100644 --- a/workflows/gitlab-ci.yml +++ b/workflows/gitlab-ci.yml @@ -43,6 +43,9 @@ socket-security: # Required for GitLab integration to work properly variables: SOCKET_SECURITY_API_KEY: $SOCKET_SECURITY_API_KEY + # GitLab token for API access - supports both authentication patterns: + # 1. CI_JOB_TOKEN: Built-in GitLab CI token (automatically uses Bearer auth) + # 2. Personal Access Token: Custom token (auto-detects Bearer vs PRIVATE-TOKEN) GITLAB_TOKEN: $CI_JOB_TOKEN # Optional: Run only when manifest files change (more efficient)