From 3d3ef0c9b5f99044c92072b241f470d2dc9fe9d2 Mon Sep 17 00:00:00 2001 From: Jonathan Edey <145066863+jonathanedey@users.noreply.github.com> Date: Mon, 8 Sep 2025 15:05:59 -0400 Subject: [PATCH 1/9] fix(auth): Fixed auth error code parsing (#908) --- firebase_admin/_auth_utils.py | 2 +- integration/test_auth.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/firebase_admin/_auth_utils.py b/firebase_admin/_auth_utils.py index a514442c..8f3c419a 100644 --- a/firebase_admin/_auth_utils.py +++ b/firebase_admin/_auth_utils.py @@ -479,7 +479,7 @@ def _parse_error_body(response): separator = code.find(':') if separator != -1: custom_message = code[separator + 1:].strip() - code = code[:separator] + code = code[:separator].strip() return code, custom_message diff --git a/integration/test_auth.py b/integration/test_auth.py index 7f4725df..b36063d1 100644 --- a/integration/test_auth.py +++ b/integration/test_auth.py @@ -724,6 +724,19 @@ def test_email_sign_in_with_settings(new_user_email_unverified, api_key): assert id_token is not None and len(id_token) > 0 assert auth.get_user(new_user_email_unverified.uid).email_verified +def test_auth_error_parse(new_user_email_unverified): + action_code_settings = auth.ActionCodeSettings( + ACTION_LINK_CONTINUE_URL, handle_code_in_app=True, link_domain="cool.link") + with pytest.raises(auth.InvalidHostingLinkDomainError) as excinfo: + auth.generate_sign_in_with_email_link(new_user_email_unverified.email, + action_code_settings=action_code_settings) + assert str(excinfo.value) == ('The provided hosting link domain is not configured in Firebase ' + 'Hosting or is not owned by the current project ' + '(INVALID_HOSTING_LINK_DOMAIN). The provided hosting link ' + 'domain is not configured in Firebase Hosting or is not owned ' + 'by the current project. This cannot be a default hosting domain ' + '(web.app or firebaseapp.com).') + @pytest.fixture(scope='module') def oidc_provider(): From de713d21da83b1f50c24c5a23132ffc442700448 Mon Sep 17 00:00:00 2001 From: Jonathan Edey <145066863+jonathanedey@users.noreply.github.com> Date: Tue, 16 Sep 2025 09:33:19 -0400 Subject: [PATCH 2/9] chore: Removed invalid `asyncio_default_fixture_loop_scope` config (#912) --- setup.cfg | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 32e00676..4c6cf8d8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,4 +1,3 @@ [tool:pytest] testpaths = tests asyncio_default_test_loop_scope = class -asyncio_default_fixture_loop_scope = None From ee8fd701def6ae4252af5a846ea70c85be8d4cfe Mon Sep 17 00:00:00 2001 From: Jonathan Edey <145066863+jonathanedey@users.noreply.github.com> Date: Wed, 17 Sep 2025 17:22:29 -0400 Subject: [PATCH 3/9] fix(functions): Refresh credentials before enqueueing first task (#907) * fix(functions): Refresh credentials before enqueueing task This change addresses an issue where enqueueing a task from a Cloud Function would fail with a InvalidArgumentError error. This was caused by uninitialized credentials being used to in the task payload. The fix explicitly refreshes the credential before accessing the credential, ensuring a valid token or service account email is used in the in the task payload. This also includes a correction for an f-string typo in the Authorization header construction. * fix(functions): Move credential refresh to functions service init * fix(functions): Moved credential refresh to run on task payload update with freshness guard --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- firebase_admin/functions.py | 16 +++++++++-- tests/test_functions.py | 57 +++++++++++++++++++++++++++++++++++++ tests/testutils.py | 31 +++++++++++++++++++- 3 files changed, 101 insertions(+), 3 deletions(-) diff --git a/firebase_admin/functions.py b/firebase_admin/functions.py index 6db0fbb4..8e77d856 100644 --- a/firebase_admin/functions.py +++ b/firebase_admin/functions.py @@ -22,7 +22,11 @@ from base64 import b64encode from typing import Any, Optional, Dict from dataclasses import dataclass + from google.auth.compute_engine import Credentials as ComputeEngineCredentials +from google.auth.credentials import TokenState +from google.auth.exceptions import RefreshError +from google.auth.transport import requests as google_auth_requests import requests import firebase_admin @@ -285,14 +289,22 @@ def _update_task_payload(self, task: Task, resource: Resource, extension_id: str # Get function url from task or generate from resources if not _Validators.is_non_empty_string(task.http_request['url']): task.http_request['url'] = self._get_url(resource, _FIREBASE_FUNCTION_URL_FORMAT) + + # Refresh the credential to ensure all attributes (e.g. service_account_email, id_token) + # are populated, preventing cold start errors. + if self._credential.token_state != TokenState.FRESH: + try: + self._credential.refresh(google_auth_requests.Request()) + except RefreshError as err: + raise ValueError(f'Initial task payload credential refresh failed: {err}') from err + # If extension id is provided, it emplies that it is being run from a deployed extension. # Meaning that it's credential should be a Compute Engine Credential. if _Validators.is_non_empty_string(extension_id) and \ isinstance(self._credential, ComputeEngineCredentials): - id_token = self._credential.token task.http_request['headers'] = \ - {**task.http_request['headers'], 'Authorization': f'Bearer ${id_token}'} + {**task.http_request['headers'], 'Authorization': f'Bearer {id_token}'} # Delete oidc token del task.http_request['oidc_token'] else: diff --git a/tests/test_functions.py b/tests/test_functions.py index 52e92c1b..95356344 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -124,6 +124,10 @@ def test_task_enqueue(self): assert recorder[0].headers['x-goog-api-client'] == expected_metrics_header assert task_id == 'test-task-id' + task = json.loads(recorder[0].body.decode())['task'] + assert task['http_request']['oidc_token'] == {'service_account_email': 'mock-email'} + assert task['http_request']['headers'] == {'Content-Type': 'application/json'} + def test_task_enqueue_with_extension(self): resource_name = ( 'projects/test-project/locations/us-central1/queues/' @@ -142,6 +146,59 @@ def test_task_enqueue_with_extension(self): assert recorder[0].headers['x-goog-api-client'] == expected_metrics_header assert task_id == 'test-task-id' + task = json.loads(recorder[0].body.decode())['task'] + assert task['http_request']['oidc_token'] == {'service_account_email': 'mock-email'} + assert task['http_request']['headers'] == {'Content-Type': 'application/json'} + + def test_task_enqueue_compute_engine(self): + app = firebase_admin.initialize_app( + testutils.MockComputeEngineCredential(), + options={'projectId': 'test-project'}, + name='test-project-gce') + _, recorder = self._instrument_functions_service(app) + queue = functions.task_queue('test-function-name', app=app) + task_id = queue.enqueue(_DEFAULT_DATA) + assert len(recorder) == 1 + assert recorder[0].method == 'POST' + assert recorder[0].url == _DEFAULT_REQUEST_URL + assert recorder[0].headers['Content-Type'] == 'application/json' + assert recorder[0].headers['Authorization'] == 'Bearer mock-compute-engine-token' + expected_metrics_header = _utils.get_metrics_header() + ' mock-gce-cred-metric-tag' + assert recorder[0].headers['x-goog-api-client'] == expected_metrics_header + assert task_id == 'test-task-id' + + task = json.loads(recorder[0].body.decode())['task'] + assert task['http_request']['oidc_token'] == {'service_account_email': 'mock-gce-email'} + assert task['http_request']['headers'] == {'Content-Type': 'application/json'} + + def test_task_enqueue_with_extension_compute_engine(self): + resource_name = ( + 'projects/test-project/locations/us-central1/queues/' + 'ext-test-extension-id-test-function-name/tasks' + ) + extension_response = json.dumps({'name': resource_name + '/test-task-id'}) + app = firebase_admin.initialize_app( + testutils.MockComputeEngineCredential(), + options={'projectId': 'test-project'}, + name='test-project-gce-extensions') + _, recorder = self._instrument_functions_service(app, payload=extension_response) + queue = functions.task_queue('test-function-name', 'test-extension-id', app) + task_id = queue.enqueue(_DEFAULT_DATA) + assert len(recorder) == 1 + assert recorder[0].method == 'POST' + assert recorder[0].url == _CLOUD_TASKS_URL + resource_name + assert recorder[0].headers['Content-Type'] == 'application/json' + assert recorder[0].headers['Authorization'] == 'Bearer mock-compute-engine-token' + expected_metrics_header = _utils.get_metrics_header() + ' mock-gce-cred-metric-tag' + assert recorder[0].headers['x-goog-api-client'] == expected_metrics_header + assert task_id == 'test-task-id' + + task = json.loads(recorder[0].body.decode())['task'] + assert 'oidc_token' not in task['http_request'] + assert task['http_request']['headers'] == { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer mock-compute-engine-token'} + def test_task_delete(self): _, recorder = self._instrument_functions_service() queue = functions.task_queue('test-function-name') diff --git a/tests/testutils.py b/tests/testutils.py index 598a929b..7546595a 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -116,12 +116,25 @@ def __call__(self, *args, **kwargs): # pylint: disable=arguments-differ # pylint: disable=abstract-method class MockGoogleCredential(credentials.Credentials): """A mock Google authentication credential.""" + + def __init__(self): + super().__init__() + self.token = None + self._service_account_email = None + self._token_state = credentials.TokenState.INVALID + def refresh(self, request): self.token = 'mock-token' + self._service_account_email = 'mock-email' + self._token_state = credentials.TokenState.FRESH + + @property + def token_state(self): + return self._token_state @property def service_account_email(self): - return 'mock-email' + return self._service_account_email # Simulate x-goog-api-client modification in credential refresh def _metric_header_for_usage(self): @@ -139,8 +152,24 @@ def get_credential(self): class MockGoogleComputeEngineCredential(compute_engine.Credentials): """A mock Compute Engine credential""" + + def __init__(self): + super().__init__() + self.token = None + self._service_account_email = None + self._token_state = credentials.TokenState.INVALID + def refresh(self, request): self.token = 'mock-compute-engine-token' + self._service_account_email = 'mock-gce-email' + self._token_state = credentials.TokenState.FRESH + + @property + def token_state(self): + return self._token_state + + def _metric_header_for_usage(self): + return 'mock-gce-cred-metric-tag' class MockComputeEngineCredential(firebase_admin.credentials.Base): """A mock Firebase credential implementation.""" From f85a8de1b5d9a252971827d2a6c075d59d564004 Mon Sep 17 00:00:00 2001 From: Jonathan Edey <145066863+jonathanedey@users.noreply.github.com> Date: Mon, 29 Sep 2025 15:27:56 -0400 Subject: [PATCH 4/9] chore: Fix typo (#913) --- firebase_admin/credentials.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase_admin/credentials.py b/firebase_admin/credentials.py index 7117b71a..0edbecaa 100644 --- a/firebase_admin/credentials.py +++ b/firebase_admin/credentials.py @@ -37,7 +37,7 @@ AccessTokenInfo = collections.namedtuple('AccessTokenInfo', ['access_token', 'expiry']) """Data included in an OAuth2 access token. -Contains the access token string and the expiry time. The expirty time is exposed as a +Contains the access token string and the expiry time. The expiry time is exposed as a ``datetime`` value. """ From fc6c8ee67ea29fe498e7cfca907a5c9fa41a3fed Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Nov 2025 15:43:48 +0000 Subject: [PATCH 5/9] chore(deps): bump pylint from 3.3.7 to 3.3.9 (#917) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c68d71a0..3b96eea0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ astroid == 3.3.11 -pylint == 3.3.7 +pylint == 3.3.9 pytest >= 8.2.2 pytest-cov >= 2.4.0 pytest-localserver >= 0.4.1 From 2305519d058afb5aaaa326e790cc52690ec596f6 Mon Sep 17 00:00:00 2001 From: Jonathan Edey <145066863+jonathanedey@users.noreply.github.com> Date: Wed, 3 Dec 2025 12:46:03 -0500 Subject: [PATCH 6/9] feat(functions): Enable Cloud Task Queue Emulator support (#920) * feat(functions): Enable Cloud Task Queue Emulator support * fix: lint * fix: Resolved issues from gemini review * chore: Added basic integration tests for task enqueue and delete * chore: Setup emulator testing for Functions integration tests * fix: Re-added accidentally removed lint * fix: integration test default apps * fix: lint --- .github/workflows/ci.yml | 22 ++++- CONTRIBUTING.md | 11 +++ firebase_admin/functions.py | 97 ++++++++++++++++--- integration/emulators/.gitignore | 69 +++++++++++++ integration/emulators/firebase.json | 29 ++++++ integration/emulators/functions/.gitignore | 6 ++ integration/emulators/functions/main.py | 7 ++ .../emulators/functions/requirements.txt | 1 + integration/test_functions.py | 52 ++++++++-- tests/test_functions.py | 91 +++++++++++++---- 10 files changed, 336 insertions(+), 49 deletions(-) create mode 100644 integration/emulators/.gitignore create mode 100644 integration/emulators/firebase.json create mode 100644 integration/emulators/functions/.gitignore create mode 100644 integration/emulators/functions/main.py create mode 100644 integration/emulators/functions/requirements.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfd29e2c..2ba09880 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,17 @@ jobs: steps: - uses: actions/checkout@v4 + + - name: Set up Python 3.13 for emulator + uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Setup functions emulator environment + run: | + python -m venv integration/emulators/functions/venv + source integration/emulators/functions/venv/bin/activate + pip install -r integration/emulators/functions/requirements.txt + deactivate - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v5 with: @@ -26,11 +37,12 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20 - - name: Run integration tests against emulator - run: | - npm install -g firebase-tools - firebase emulators:exec --only database --project fake-project-id 'pytest integration/test_db.py' - + - name: Install firebase-tools + run: npm install -g firebase-tools + - name: Run Database emulator tests + run: firebase emulators:exec --only database --project fake-project-id 'pytest integration/test_db.py' + - name: Run Functions emulator tests + run: firebase emulators:exec --config integration/emulators/firebase.json --only tasks,functions --project fake-project-id 'CLOUD_TASKS_EMULATOR_HOST=localhost:9499 pytest integration/test_functions.py' lint: runs-on: ubuntu-latest steps: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 72933a24..71da12dc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -252,6 +252,17 @@ to ensure that exported user records contain the password hashes of the user acc 3. Click **ADD ANOTHER ROLE** and choose **Firebase Authentication Admin**. 4. Click **SAVE**. +9. Enable Cloud Tasks: + 1. Search for and enable **Cloud Run**. + 2. Search for and enable **Cloud Tasks**. + 3. Go to [Google Cloud console | IAM & admin](https://console.cloud.google.com/iam-admin) + and make sure your Firebase project is selected. + 4. Ensure your service account has the following required roles: + * **Cloud Tasks Enqueuer** - `cloudtasks.taskEnqueuer` + * **Cloud Tasks Task Deleter** - `cloudtasks.taskDeleter` + * **Cloud Run Invoker** - `run.invoker` + * **Service Account User** - `iam.serviceAccountUser` + Now you can invoke the integration test suite as follows: diff --git a/firebase_admin/functions.py b/firebase_admin/functions.py index 8e77d856..66ba700b 100644 --- a/firebase_admin/functions.py +++ b/firebase_admin/functions.py @@ -18,6 +18,7 @@ from datetime import datetime, timedelta, timezone from urllib import parse import re +import os import json from base64 import b64encode from typing import Any, Optional, Dict @@ -49,6 +50,8 @@ 'https://cloudtasks.googleapis.com/v2/' + _CLOUD_TASKS_API_RESOURCE_PATH _FIREBASE_FUNCTION_URL_FORMAT = \ 'https://{location_id}-{project_id}.cloudfunctions.net/{resource_id}' +_EMULATOR_HOST_ENV_VAR = 'CLOUD_TASKS_EMULATOR_HOST' +_EMULATED_SERVICE_ACCOUNT_DEFAULT = 'emulated-service-acct@email.com' _FUNCTIONS_HEADERS = { 'X-GOOG-API-FORMAT-VERSION': '2', @@ -58,6 +61,17 @@ # Default canonical location ID of the task queue. _DEFAULT_LOCATION = 'us-central1' +def _get_emulator_host() -> Optional[str]: + emulator_host = os.environ.get(_EMULATOR_HOST_ENV_VAR) + if emulator_host: + if '//' in emulator_host: + raise ValueError( + f'Invalid {_EMULATOR_HOST_ENV_VAR}: "{emulator_host}". It must follow format ' + '"host:port".') + return emulator_host + return None + + def _get_functions_service(app) -> _FunctionsService: return _utils.get_app_service(app, _FUNCTIONS_ATTRIBUTE, _FunctionsService) @@ -103,13 +117,19 @@ def __init__(self, app: App): 'projectId option, or use service account credentials. Alternatively, set the ' 'GOOGLE_CLOUD_PROJECT environment variable.') - self._credential = app.credential.get_credential() + self._emulator_host = _get_emulator_host() + if self._emulator_host: + self._credential = _utils.EmulatorAdminCredentials() + else: + self._credential = app.credential.get_credential() + self._http_client = _http_client.JsonHttpClient(credential=self._credential) def task_queue(self, function_name: str, extension_id: Optional[str] = None) -> TaskQueue: """Creates a TaskQueue instance.""" return TaskQueue( - function_name, extension_id, self._project_id, self._credential, self._http_client) + function_name, extension_id, self._project_id, self._credential, self._http_client, + self._emulator_host) @classmethod def handle_functions_error(cls, error: Any): @@ -125,7 +145,8 @@ def __init__( extension_id: Optional[str], project_id, credential, - http_client + http_client, + emulator_host: Optional[str] = None ) -> None: # Validate function_name @@ -134,6 +155,7 @@ def __init__( self._project_id = project_id self._credential = credential self._http_client = http_client + self._emulator_host = emulator_host self._function_name = function_name self._extension_id = extension_id # Parse resources from function_name @@ -167,16 +189,26 @@ def enqueue(self, task_data: Any, opts: Optional[TaskOptions] = None) -> str: str: The ID of the task relative to this queue. """ task = self._validate_task_options(task_data, self._resource, opts) - service_url = self._get_url(self._resource, _CLOUD_TASKS_API_URL_FORMAT) + emulator_url = self._get_emulator_url(self._resource) + service_url = emulator_url or self._get_url(self._resource, _CLOUD_TASKS_API_URL_FORMAT) task_payload = self._update_task_payload(task, self._resource, self._extension_id) try: resp = self._http_client.body( 'post', url=service_url, headers=_FUNCTIONS_HEADERS, - json={'task': task_payload.__dict__} + json={'task': task_payload.to_api_dict()} ) - task_name = resp.get('name', None) + if self._is_emulated(): + # Emulator returns a response with format {task: {name: }} + # The task name also has an extra '/' at the start compared to prod + task_info = resp.get('task') or {} + task_name = task_info.get('name') + if task_name: + task_name = task_name[1:] + else: + # Production returns a response with format {name: } + task_name = resp.get('name') task_resource = \ self._parse_resource_name(task_name, f'queues/{self._resource.resource_id}/tasks') return task_resource.resource_id @@ -197,7 +229,11 @@ def delete(self, task_id: str) -> None: ValueError: If the input arguments are invalid. """ _Validators.check_non_empty_string('task_id', task_id) - service_url = self._get_url(self._resource, _CLOUD_TASKS_API_URL_FORMAT + f'/{task_id}') + emulator_url = self._get_emulator_url(self._resource) + if emulator_url: + service_url = emulator_url + f'/{task_id}' + else: + service_url = self._get_url(self._resource, _CLOUD_TASKS_API_URL_FORMAT + f'/{task_id}') try: self._http_client.body( 'delete', @@ -235,8 +271,8 @@ def _validate_task_options( """Validate and create a Task from optional ``TaskOptions``.""" task_http_request = { 'url': '', - 'oidc_token': { - 'service_account_email': '' + 'oidcToken': { + 'serviceAccountEmail': '' }, 'body': b64encode(json.dumps(data).encode()).decode(), 'headers': { @@ -250,7 +286,7 @@ def _validate_task_options( task.http_request['headers'] = {**task.http_request['headers'], **opts.headers} if opts.schedule_time is not None and opts.schedule_delay_seconds is not None: raise ValueError( - 'Both sechdule_delay_seconds and schedule_time cannot be set at the same time.') + 'Both schedule_delay_seconds and schedule_time cannot be set at the same time.') if opts.schedule_time is not None and opts.schedule_delay_seconds is None: if not isinstance(opts.schedule_time, datetime): raise ValueError('schedule_time should be UTC datetime.') @@ -288,7 +324,10 @@ def _update_task_payload(self, task: Task, resource: Resource, extension_id: str """Prepares task to be sent with credentials.""" # Get function url from task or generate from resources if not _Validators.is_non_empty_string(task.http_request['url']): - task.http_request['url'] = self._get_url(resource, _FIREBASE_FUNCTION_URL_FORMAT) + if self._is_emulated(): + task.http_request['url'] = '' + else: + task.http_request['url'] = self._get_url(resource, _FIREBASE_FUNCTION_URL_FORMAT) # Refresh the credential to ensure all attributes (e.g. service_account_email, id_token) # are populated, preventing cold start errors. @@ -298,7 +337,7 @@ def _update_task_payload(self, task: Task, resource: Resource, extension_id: str except RefreshError as err: raise ValueError(f'Initial task payload credential refresh failed: {err}') from err - # If extension id is provided, it emplies that it is being run from a deployed extension. + # If extension id is provided, it implies that it is being run from a deployed extension. # Meaning that it's credential should be a Compute Engine Credential. if _Validators.is_non_empty_string(extension_id) and \ isinstance(self._credential, ComputeEngineCredentials): @@ -306,12 +345,32 @@ def _update_task_payload(self, task: Task, resource: Resource, extension_id: str task.http_request['headers'] = \ {**task.http_request['headers'], 'Authorization': f'Bearer {id_token}'} # Delete oidc token - del task.http_request['oidc_token'] + del task.http_request['oidcToken'] else: - task.http_request['oidc_token'] = \ - {'service_account_email': self._credential.service_account_email} + try: + task.http_request['oidcToken'] = \ + {'serviceAccountEmail': self._credential.service_account_email} + except AttributeError as error: + if self._is_emulated(): + task.http_request['oidcToken'] = \ + {'serviceAccountEmail': _EMULATED_SERVICE_ACCOUNT_DEFAULT} + else: + raise ValueError( + 'Failed to determine service account. Initialize the SDK with service ' + 'account credentials or set service account ID as an app option.' + ) from error return task + def _get_emulator_url(self, resource: Resource): + if self._emulator_host: + emulator_url_format = f'http://{self._emulator_host}/' + _CLOUD_TASKS_API_RESOURCE_PATH + url = self._get_url(resource, emulator_url_format) + return url + return None + + def _is_emulated(self): + return self._emulator_host is not None + class _Validators: """A collection of data validation utilities.""" @@ -436,6 +495,14 @@ class Task: schedule_time: Optional[str] = None dispatch_deadline: Optional[str] = None + def to_api_dict(self) -> dict: + """Converts the Task object to a dictionary suitable for the Cloud Tasks API.""" + return { + 'httpRequest': self.http_request, + 'name': self.name, + 'scheduleTime': self.schedule_time, + 'dispatchDeadline': self.dispatch_deadline, + } @dataclass class Resource: diff --git a/integration/emulators/.gitignore b/integration/emulators/.gitignore new file mode 100644 index 00000000..b17f6310 --- /dev/null +++ b/integration/emulators/.gitignore @@ -0,0 +1,69 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +firebase-debug.log* +firebase-debug.*.log* + +# Firebase cache +.firebase/ + +# Firebase config + +# Uncomment this if you'd like others to create their own Firebase project. +# For a team working on the same Firebase project(s), it is recommended to leave +# it commented so all members can deploy to the same project(s) in .firebaserc. +# .firebaserc + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# dataconnect generated files +.dataconnect diff --git a/integration/emulators/firebase.json b/integration/emulators/firebase.json new file mode 100644 index 00000000..a7b727c4 --- /dev/null +++ b/integration/emulators/firebase.json @@ -0,0 +1,29 @@ +{ + "emulators": { + "tasks": { + "port": 9499 + }, + "ui": { + "enabled": false + }, + "singleProjectMode": true, + "functions": { + "port": 5001 + } + }, + "functions": [ + { + "source": "functions", + "codebase": "default", + "disallowLegacyRuntimeConfig": true, + "ignore": [ + "venv", + ".git", + "firebase-debug.log", + "firebase-debug.*.log", + "*.local" + ], + "runtime": "python313" + } + ] +} diff --git a/integration/emulators/functions/.gitignore b/integration/emulators/functions/.gitignore new file mode 100644 index 00000000..1609bab7 --- /dev/null +++ b/integration/emulators/functions/.gitignore @@ -0,0 +1,6 @@ +# Python bytecode +__pycache__/ + +# Python virtual environment +venv/ +*.local diff --git a/integration/emulators/functions/main.py b/integration/emulators/functions/main.py new file mode 100644 index 00000000..6cd2c576 --- /dev/null +++ b/integration/emulators/functions/main.py @@ -0,0 +1,7 @@ +from firebase_functions import tasks_fn + +@tasks_fn.on_task_dispatched() +def testTaskQueue(req: tasks_fn.CallableRequest) -> None: + """Handles tasks from the task queue.""" + print(f"Received task with data: {req.data}") + return diff --git a/integration/emulators/functions/requirements.txt b/integration/emulators/functions/requirements.txt new file mode 100644 index 00000000..6bbab42f --- /dev/null +++ b/integration/emulators/functions/requirements.txt @@ -0,0 +1 @@ +firebase_functions~=0.4.1 diff --git a/integration/test_functions.py b/integration/test_functions.py index 60679843..fc972f9e 100644 --- a/integration/test_functions.py +++ b/integration/test_functions.py @@ -14,17 +14,34 @@ """Integration tests for firebase_admin.functions module.""" +import os import pytest import firebase_admin from firebase_admin import functions +from firebase_admin import _utils from integration import conftest +_DEFAULT_DATA = {'data': {'city': 'Seattle'}} +def integration_conf(request): + host_override = os.environ.get('CLOUD_TASKS_EMULATOR_HOST') + if host_override: + return _utils.EmulatorAdminCredentials(), 'fake-project-id' + + return conftest.integration_conf(request) + @pytest.fixture(scope='module') def app(request): - cred, _ = conftest.integration_conf(request) - return firebase_admin.initialize_app(cred, name='integration-functions') + cred, project_id = integration_conf(request) + return firebase_admin.initialize_app( + cred, options={'projectId': project_id}, name='integration-functions') + +@pytest.fixture(scope='module', autouse=True) +def default_app(): + # Overwrites the default_app fixture in conftest.py. + # This test suite should not use the default app. Use the app fixture instead. + pass class TestFunctions: @@ -41,16 +58,31 @@ class TestFunctions: ] @pytest.mark.parametrize('task_queue_params', _TEST_FUNCTIONS_PARAMS) - def test_task_queue(self, task_queue_params): - queue = functions.task_queue(**task_queue_params) - assert queue is not None - assert callable(queue.enqueue) - assert callable(queue.delete) - - @pytest.mark.parametrize('task_queue_params', _TEST_FUNCTIONS_PARAMS) - def test_task_queue_app(self, task_queue_params, app): + def test_task_queue(self, task_queue_params, app): assert app.name == 'integration-functions' queue = functions.task_queue(**task_queue_params, app=app) assert queue is not None assert callable(queue.enqueue) assert callable(queue.delete) + + def test_task_enqueue(self, app): + queue = functions.task_queue('testTaskQueue', app=app) + task_id = queue.enqueue(_DEFAULT_DATA) + assert task_id is not None + + @pytest.mark.skipif( + os.environ.get('CLOUD_TASKS_EMULATOR_HOST') is not None, + reason="Skipping test_task_delete against emulator due to bug in firebase-tools" + ) + def test_task_delete(self, app): + # Skip this test against the emulator since tasks can't be delayed there to verify deletion + # See: https://github.com/firebase/firebase-tools/issues/8254 + task_options = functions.TaskOptions(schedule_delay_seconds=60) + queue = functions.task_queue('testTaskQueue', app=app) + task_id = queue.enqueue(_DEFAULT_DATA, task_options) + assert task_id is not None + queue.delete(task_id) + # We don't have a way to check the contents of the queue so we check that the deleted + # task is not found using the delete method again. + with pytest.raises(firebase_admin.exceptions.NotFoundError): + queue.delete(task_id) diff --git a/tests/test_functions.py b/tests/test_functions.py index 95356344..0f766767 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -44,13 +44,14 @@ def setup_class(cls): def teardown_class(cls): testutils.cleanup_apps() - def _instrument_functions_service(self, app=None, status=200, payload=_DEFAULT_RESPONSE): + def _instrument_functions_service( + self, app=None, status=200, payload=_DEFAULT_RESPONSE, mounted_url=_CLOUD_TASKS_URL): if not app: app = firebase_admin.get_app() functions_service = functions._get_functions_service(app) recorder = [] functions_service._http_client.session.mount( - _CLOUD_TASKS_URL, + mounted_url, testutils.MockAdapter(payload, status, recorder)) return functions_service, recorder @@ -125,8 +126,8 @@ def test_task_enqueue(self): assert task_id == 'test-task-id' task = json.loads(recorder[0].body.decode())['task'] - assert task['http_request']['oidc_token'] == {'service_account_email': 'mock-email'} - assert task['http_request']['headers'] == {'Content-Type': 'application/json'} + assert task['httpRequest']['oidcToken'] == {'serviceAccountEmail': 'mock-email'} + assert task['httpRequest']['headers'] == {'Content-Type': 'application/json'} def test_task_enqueue_with_extension(self): resource_name = ( @@ -147,8 +148,8 @@ def test_task_enqueue_with_extension(self): assert task_id == 'test-task-id' task = json.loads(recorder[0].body.decode())['task'] - assert task['http_request']['oidc_token'] == {'service_account_email': 'mock-email'} - assert task['http_request']['headers'] == {'Content-Type': 'application/json'} + assert task['httpRequest']['oidcToken'] == {'serviceAccountEmail': 'mock-email'} + assert task['httpRequest']['headers'] == {'Content-Type': 'application/json'} def test_task_enqueue_compute_engine(self): app = firebase_admin.initialize_app( @@ -168,8 +169,8 @@ def test_task_enqueue_compute_engine(self): assert task_id == 'test-task-id' task = json.loads(recorder[0].body.decode())['task'] - assert task['http_request']['oidc_token'] == {'service_account_email': 'mock-gce-email'} - assert task['http_request']['headers'] == {'Content-Type': 'application/json'} + assert task['httpRequest']['oidcToken'] == {'serviceAccountEmail': 'mock-gce-email'} + assert task['httpRequest']['headers'] == {'Content-Type': 'application/json'} def test_task_enqueue_with_extension_compute_engine(self): resource_name = ( @@ -194,8 +195,8 @@ def test_task_enqueue_with_extension_compute_engine(self): assert task_id == 'test-task-id' task = json.loads(recorder[0].body.decode())['task'] - assert 'oidc_token' not in task['http_request'] - assert task['http_request']['headers'] == { + assert 'oidcToken' not in task['httpRequest'] + assert task['httpRequest']['headers'] == { 'Content-Type': 'application/json', 'Authorization': 'Bearer mock-compute-engine-token'} @@ -209,6 +210,58 @@ def test_task_delete(self): expected_metrics_header = _utils.get_metrics_header() + ' mock-cred-metric-tag' assert recorder[0].headers['x-goog-api-client'] == expected_metrics_header + def test_task_enqueue_with_emulator_host(self, monkeypatch): + emulator_host = 'localhost:8124' + emulator_url = f'http://{emulator_host}/' + request_url = emulator_url + _DEFAULT_TASK_PATH.replace('/tasks/test-task-id', '/tasks') + + monkeypatch.setenv('CLOUD_TASKS_EMULATOR_HOST', emulator_host) + app = firebase_admin.initialize_app( + _utils.EmulatorAdminCredentials(), {'projectId': 'test-project'}, name='emulator-app') + + expected_task_name = ( + '/projects/test-project/locations/us-central1' + '/queues/test-function-name/tasks/test-task-id' + ) + expected_response = json.dumps({'task': {'name': expected_task_name}}) + _, recorder = self._instrument_functions_service( + app, payload=expected_response, mounted_url=emulator_url) + + queue = functions.task_queue('test-function-name', app=app) + task_id = queue.enqueue(_DEFAULT_DATA) + + assert len(recorder) == 1 + assert recorder[0].method == 'POST' + assert recorder[0].url == request_url + assert recorder[0].headers['Content-Type'] == 'application/json' + + task = json.loads(recorder[0].body.decode())['task'] + assert task['httpRequest']['oidcToken'] == { + 'serviceAccountEmail': 'emulated-service-acct@email.com' + } + assert task_id == 'test-task-id' + + def test_task_enqueue_without_emulator_host_error(self, monkeypatch): + app = firebase_admin.initialize_app( + _utils.EmulatorAdminCredentials(), + {'projectId': 'test-project'}, name='no-emulator-app') + + _, recorder = self._instrument_functions_service(app) + monkeypatch.delenv('CLOUD_TASKS_EMULATOR_HOST', raising=False) + queue = functions.task_queue('test-function-name', app=app) + with pytest.raises(ValueError) as excinfo: + queue.enqueue(_DEFAULT_DATA) + assert "Failed to determine service account" in str(excinfo.value) + assert len(recorder) == 0 + + def test_get_emulator_url_invalid_format(self, monkeypatch): + monkeypatch.setenv('CLOUD_TASKS_EMULATOR_HOST', 'http://localhost:8124') + app = firebase_admin.initialize_app( + testutils.MockCredential(), {'projectId': 'test-project'}, name='invalid-host-app') + with pytest.raises(ValueError) as excinfo: + functions.task_queue('test-function-name', app=app) + assert 'Invalid CLOUD_TASKS_EMULATOR_HOST' in str(excinfo.value) + class TestTaskQueueOptions: _DEFAULT_TASK_OPTS = {'schedule_delay_seconds': None, 'schedule_time': None, \ @@ -259,13 +312,13 @@ def test_task_options_delay_seconds(self): assert len(recorder) == 1 task = json.loads(recorder[0].body.decode())['task'] - task_schedule_time = datetime.fromisoformat(task['schedule_time'].replace('Z', '+00:00')) + task_schedule_time = datetime.fromisoformat(task['scheduleTime'].replace('Z', '+00:00')) delta = abs(task_schedule_time - expected_schedule_time) assert delta <= timedelta(seconds=1) - assert task['dispatch_deadline'] == '200s' - assert task['http_request']['headers']['x-test-header'] == 'test-header-value' - assert task['http_request']['url'] in ['http://google.com', 'https://google.com'] + assert task['dispatchDeadline'] == '200s' + assert task['httpRequest']['headers']['x-test-header'] == 'test-header-value' + assert task['httpRequest']['url'] in ['http://google.com', 'https://google.com'] assert task['name'] == _DEFAULT_TASK_PATH def test_task_options_utc_time(self): @@ -287,12 +340,12 @@ def test_task_options_utc_time(self): assert len(recorder) == 1 task = json.loads(recorder[0].body.decode())['task'] - task_schedule_time = datetime.fromisoformat(task['schedule_time'].replace('Z', '+00:00')) + task_schedule_time = datetime.fromisoformat(task['scheduleTime'].replace('Z', '+00:00')) assert task_schedule_time == expected_schedule_time - assert task['dispatch_deadline'] == '200s' - assert task['http_request']['headers']['x-test-header'] == 'test-header-value' - assert task['http_request']['url'] in ['http://google.com', 'https://google.com'] + assert task['dispatchDeadline'] == '200s' + assert task['httpRequest']['headers']['x-test-header'] == 'test-header-value' + assert task['httpRequest']['url'] in ['http://google.com', 'https://google.com'] assert task['name'] == _DEFAULT_TASK_PATH def test_schedule_set_twice_error(self): @@ -304,7 +357,7 @@ def test_schedule_set_twice_error(self): queue.enqueue(_DEFAULT_DATA, opts) assert len(recorder) == 0 assert str(excinfo.value) == \ - 'Both sechdule_delay_seconds and schedule_time cannot be set at the same time.' + 'Both schedule_delay_seconds and schedule_time cannot be set at the same time.' @pytest.mark.parametrize('schedule_time', [ From 807e7e1d8abdf37a8413f42864c12bebce89fd21 Mon Sep 17 00:00:00 2001 From: Jonathan Edey <145066863+jonathanedey@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:32:31 -0500 Subject: [PATCH 7/9] chore: Fix auth snippet typo (#924) --- snippets/auth/index.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snippets/auth/index.py b/snippets/auth/index.py index 6a509b8f..656137db 100644 --- a/snippets/auth/index.py +++ b/snippets/auth/index.py @@ -770,7 +770,7 @@ def get_tenant(tenant_id): # [START get_tenant] tenant = tenant_mgt.get_tenant(tenant_id) - print('Retreieved tenant:', tenant.tenant_id) + print('Retrieved tenant:', tenant.tenant_id) # [END get_tenant] def create_tenant(): From d5aba8443196e0212d724bd7b81f73689b5c8a08 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Fri, 12 Dec 2025 17:16:11 -0500 Subject: [PATCH 8/9] chore: Update default branch to `main` (#926) * chore: Update default branch to main * set java version to fix emulator tools --- .github/scripts/publish_preflight_check.sh | 4 ++-- .github/workflows/ci.yml | 6 ++++++ .github/workflows/nightly.yml | 4 ++-- .github/workflows/release.yml | 6 +++--- CONTRIBUTING.md | 2 +- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/scripts/publish_preflight_check.sh b/.github/scripts/publish_preflight_check.sh index 1d001c3b..38fe49a8 100755 --- a/.github/scripts/publish_preflight_check.sh +++ b/.github/scripts/publish_preflight_check.sh @@ -159,8 +159,8 @@ echo_info "Generating changelog" echo_info "--------------------------------------------" echo_info "" -echo_info "---< git fetch origin master --prune --unshallow >---" -git fetch origin master --prune --unshallow +echo_info "---< git fetch origin main --prune --unshallow >---" +git fetch origin main --prune --unshallow echo "" echo_info "Generating changelog from history..." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ba09880..fa980083 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,12 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20 + - name: Set up Java 21 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '21' + check-latest: true - name: Install firebase-tools run: npm install -g firebase-tools - name: Run Database emulator tests diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 3d542053..61644e80 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -70,7 +70,7 @@ jobs: - name: Send email on failure if: failure() - uses: firebase/firebase-admin-node/.github/actions/send-email@master + uses: firebase/firebase-admin-node/.github/actions/send-email@main with: api-key: ${{ secrets.OSS_BOT_MAILGUN_KEY }} domain: ${{ secrets.OSS_BOT_MAILGUN_DOMAIN }} @@ -85,7 +85,7 @@ jobs: - name: Send email on cancelled if: cancelled() - uses: firebase/firebase-admin-node/.github/actions/send-email@master + uses: firebase/firebase-admin-node/.github/actions/send-email@main with: api-key: ${{ secrets.OSS_BOT_MAILGUN_KEY }} domain: ${{ secrets.OSS_BOT_MAILGUN_DOMAIN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6cd1d3f0..738dfca5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -84,11 +84,11 @@ jobs: # Check whether the release should be published. We publish only when the trigger PR is # 1. merged - # 2. to the master branch + # 2. to the main branch # 3. with the label 'release:publish', and # 4. the title prefix '[chore] Release '. if: github.event.pull_request.merged && - github.ref == 'refs/heads/master' && + github.ref == 'refs/heads/main' && contains(github.event.pull_request.labels.*.name, 'release:publish') && startsWith(github.event.pull_request.title, '[chore] Release ') @@ -130,7 +130,7 @@ jobs: - name: Post to Twitter if: success() && contains(github.event.pull_request.labels.*.name, 'release:tweet') - uses: firebase/firebase-admin-node/.github/actions/send-tweet@master + uses: firebase/firebase-admin-node/.github/actions/send-tweet@main with: status: > ${{ steps.preflight.outputs.version }} of @Firebase Admin Python SDK is available. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 71da12dc..139e7f96 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,7 @@ Great, we love hearing how we can improve our products! Share you idea through o ## Want to submit a pull request? Sweet, we'd love to accept your contribution! -[Open a new pull request](https://github.com/firebase/firebase-admin-python/pull/new/master) and fill +[Open a new pull request](https://github.com/firebase/firebase-admin-python/pull/new) and fill out the provided template. **If you want to implement a new feature, please open an issue with a proposal first so that we can From e8276552c377d72452f6cd182ad9f4fc62982112 Mon Sep 17 00:00:00 2001 From: Jonathan Edey <145066863+jonathanedey@users.noreply.github.com> Date: Tue, 13 Jan 2026 15:54:59 -0500 Subject: [PATCH 9/9] chore: Update github actions workflows and integration test resources (#932) * chore: Pinned github actions to full-length comit SHAs * chore: Update integration test resource * chore: Added environment label to release action * Trigger integration tests --- .../resources/integ-service-account.json.gpg | Bin 1762 -> 1756 bytes .github/workflows/ci.yml | 14 +++++++------- .github/workflows/nightly.yml | 10 +++++----- .github/workflows/release.yml | 15 ++++++++------- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/.github/resources/integ-service-account.json.gpg b/.github/resources/integ-service-account.json.gpg index 7740dccd8bdada2eecc181f75c552c00e912e5c2..5a52805c9a854fdea91101dfa1822cdd5e79c874 100644 GIT binary patch literal 1756 zcmV<21|#{54Fm}T3b4{&ennUfga6X%0rw}><=tSBuPhv7FQy8=I?3Lmw%%fwb*z*z z=6KA27D5iuBgx~x$Nyfl&GpjP(Qv$$D`=U2l?uxu35Sn`=|kyz<8?VA+jsyF+7=2@ z<@|}U!==V573LAFo)Fk!&)W@2^oQ_BI8xVK1@Fz@nY52OgWsOxEWs4?ws$bNy2VpekV}20eGu8>0Z((IN{nq@79Jv z>qJM3@B0X3ROSPK`6JTc9rmfZp<%iCo=L%D$)96DgP`ezf<5a55_+{><$|JWn2{6- zu8|~zd^P6mDHo4^^s;zYo=gn$v3_|-6h#Q*v>YB4FwzzYxJ$OFI2qsh-<}WE`UP0o zvNc^F5c1`|u5^Lo6gM(449xUT0J_lPfT`o-Rv3YXMm45zU)xYv%V8E`t!^%3W2T@d zME7SXe{`62EFI}_gg}|*iwSRjWZ)dX-wOf2SENE#%c{=78oej87A$)iafd`bW8SH6 zJ-kB6gL(_ov>A`sM?z99l_M;#(lNwfS+_jn-)(p+mR%!(X&i-aQ;uoOep`8uJh{NU z`;X((7+wEarUlyb_5_u^GXdq zaHU$=fZ8&|fJOjb;?BfCUET*97w=(u`g8iTDN%`zGy@e26Rze|?C=eo-{bX~^~{(OslofH)*vmX`q?uh;mD5YR6@E1U1-|M>X0?* znFf7wQOrblD2(asM#I1dWRAPl@zh(QD z3XsUb(1C36YvZSlmD6!_%N!na+}IMOU|^m>_N`mzbT zZz&Ee7(3&M^=M8Xx_;F26O2}mG$#jR3sA&;ot)ktfCj624V{@9&f|P1`hLMTpnf^v zyhk%W;T7PT7K8gpz*`iIN{Id&dQVMOU|K%KwbY*8!(h}Mh1@VUgaWc{)H1_HNj{KH zq!~m9Ks6xb#27@Jch`+}Rsh!qa(SViGg&w78YzoJc~jys?pKii|FV@TzhUOuk}*;X z4UM8Nu9^|{7&XbCsEJX@rHSaw)%Fuy4-8YCUH<)teY{M^oANVK%EBx4h~)y7RP!u+ zG4q4$%cl%;>0k?v&6UdW=S7~lH-VQ%rOiDIg!tzMdLHwerG6__b|UZ{+x*``6FM_x z9Q%*J0L1Bea||NK+{ND-NlRa5UL%ezMBxFUxC1VlSB|DLWU6iI+7OPXmC?gb}g23VDc?X&{)zPJ~y802E;k33pZuLifi1$Fy+IgceC8QUX(c8vuYUjQKL zPLpo*M3{Hm$;qSSe&l)rR9` zt9n!#Tg#AJ^E292w0{@6@OsPYQ6>R0#1H-qumS64GunHce~@%Y9h397pn53owR5DC zt*{lGzs20XTi%=eqw1~J<2TYgq7Y{bA?-e8DSjCROK>ezwNru{_CUNLu@-pZpX7y~ z;J^s(GiVC9k!jG$m?zZX8w$6!$XoIBdUL|re!W6X_8{PtSQ38mbE@D8(%y$_zcNI6 zQOb{GR=s*=H?m`D`{_yr@g0Jr`3B&P>JtuTtoLNZgc9|vz>at=Wdh*Zk?DLam2X-i zvOz~E+b``WR>({bK{cqmo9hPH%#g(%I1-u!fh6lSWv!AmZ6+;`&jUAf%zUT!Y_d3L ye!cL($Ue&aX9i7=EYc2_I+(Y=Ru;Ur6L%VZx+|5b-zMEMj_Xxk_VrmJgD2Lh0c>#q literal 1762 zcmV<81|9i~4Fm}T0)n*iur~xoxnP%@af)-$RhZ&7)3w(?>+$-b1cw>C)uo%^A>P*=<*0iel03 za}Fsg1`ruYzno_!>aY;)OsTkc%h&&pe{$nZw5?@UjcH^%VjHu)VgwEnKQUK4HJk0= zyZ5gIrQ>xo>l)_!-wlcQad@p8gshGgv1xcg_Yjj!nW#U|n*LMBm3Wlbk?i_CZ5Ume zal+1184AaJ?*V#JRCa=MTz1K6B-nEb*&W)}8_{YN|7UTh+U!ds%WR1v?9Hbxk@ z+EogP!glp>+*yB=n_Y5O!Q;p67Vmj%K0Z&IQHoSQT$5HK`B~z1yiX&eY#WwU=~;7S z*R}F3&c9V&rDM=B?(>!Z9}?HjEfYzeD!pR<0+x@F>TU9zk7}`hv#*;%+wZyrU0HEofh5VjbOiejUW3pY z12D!+Wf#4>(VKPmD}rZ71T zja(Co^VJ`de?Q=DM7vMvigtGHs?54vGS7;%Hh%Kgpcm|%^7?KEi%S69Cr3HH;P{#2 zQKW(H3{(*{l=~Bf7Rl-tT)anXQbTFgdpURPL%zy_*wvVIb#5uC(O*IiG}XiMGUA%y z{hDOjK*5$4CjH6|s5_9ya=cleH7`i?@E_&%aD!Azwfl->_ z*G%8=hHUxvc;-w-8SZhmd?W7x5Q>Gdn&TWCqCer@&YB71xG=dZ z)$Q#4CnyByk9+}lQNhr0X%Yl^Z@x^E{?CS9l0qFDy5|%MRx3RMp&FRUbXi#z!svt^ z$1+LBRDKak{r8lR!uG7M$3y`i=mM%I6C;mOE*azp=yC&EjEO@hZAV~%vb<>vw*Tx}<@1-F%E1MJ z&Kx%>OC=ss;A2c#Xs*e!O^%Y!Dj=VPAc>{~z^9u8{@H%2A-4|8w>siL6sFHXXGAGp z6u#;-ci&~<6YWll-R0BZ(9=FJ*d=Z^1zpP&loXySLaK2LILSvW!Dc?^#B&6$r*#Z0VQL=xlo$_b_5MO*>kW6!}M4b=rO4;kC? z7>1#k9x6oenqHJ=>|?M)^;{lP0reAV^DsBc!yt{hJ=)44ze!{NMcolK=)E*z3xPIFQ}^9xIoZ8E2fWR@#G|3UOCwegz@y^#$8?WWnJ zs2>$dD-U!>e@QG8RfQO0o(=0E;V|(Wz7kVeam0oZT7r|6xiFJ zNd8hqdzqJ0-`fL-a@okskU*`zWMi=%n1PPxq!&m!cL|J#l~QZ}A3JebK?T zy$sIEE!|IkVGk9;6HzHA)9Y-IXTCA)$QGOVqdT1OhM%`yYT53qvYtc=4KjLTfqCH9 zgC*H0Jk^%P@UH!391-ApSu$Z{4D#3;W{vq*kO zsL+qpghA`Jm|4{gJrTOb%(>>3vR5{Am*n3SBI9H-+h-WW$}kgTg9MyYUUi*K+#nml Ero6&wRR910 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa980083..5bf78a56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,10 +11,10 @@ jobs: python: ['3.9', '3.10', '3.11', '3.12', '3.13', 'pypy3.9'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # 4.3.1 - name: Set up Python 3.13 for emulator - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 5.6.0 with: python-version: '3.13' - name: Setup functions emulator environment @@ -24,7 +24,7 @@ jobs: pip install -r integration/emulators/functions/requirements.txt deactivate - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 5.6.0 with: python-version: ${{ matrix.python }} - name: Install dependencies @@ -34,11 +34,11 @@ jobs: - name: Test with pytest run: pytest - name: Set up Node.js 20 - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # 4.4.0 with: node-version: 20 - name: Set up Java 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # 5.1.0 with: distribution: 'temurin' java-version: '21' @@ -52,9 +52,9 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # 4.3.1 - name: Set up Python 3.9 - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 5.6.0 with: python-version: 3.9 - name: Install dependencies diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 61644e80..d60b3cd0 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -29,12 +29,12 @@ jobs: steps: - name: Checkout source for staging - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # 4.3.1 with: ref: ${{ github.event.client_payload.ref || github.ref }} - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 5.6.0 with: python-version: 3.9 @@ -63,14 +63,14 @@ jobs: # Attach the packaged artifacts to the workflow output. These can be manually # downloaded for later inspection if necessary. - name: Archive artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: dist path: dist - name: Send email on failure if: failure() - uses: firebase/firebase-admin-node/.github/actions/send-email@main + uses: firebase/firebase-admin-node/.github/actions/send-email@2e2b36a84ba28679bcb7aecdacabfec0bded2d48 # Admin Node SDK v13.6.0 with: api-key: ${{ secrets.OSS_BOT_MAILGUN_KEY }} domain: ${{ secrets.OSS_BOT_MAILGUN_DOMAIN }} @@ -85,7 +85,7 @@ jobs: - name: Send email on cancelled if: cancelled() - uses: firebase/firebase-admin-node/.github/actions/send-email@main + uses: firebase/firebase-admin-node/.github/actions/send-email@2e2b36a84ba28679bcb7aecdacabfec0bded2d48 # Admin Node SDK v13.6.0 with: api-key: ${{ secrets.OSS_BOT_MAILGUN_KEY }} domain: ${{ secrets.OSS_BOT_MAILGUN_DOMAIN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 738dfca5..53ebe825 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,12 +40,12 @@ jobs: # via the 'ref' client parameter. steps: - name: Checkout source for staging - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # 4.3.1 with: ref: ${{ github.event.client_payload.ref || github.ref }} - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 5.6.0 with: python-version: 3.9 @@ -74,7 +74,7 @@ jobs: # Attach the packaged artifacts to the workflow output. These can be manually # downloaded for later inspection if necessary. - name: Archive artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: dist path: dist @@ -93,6 +93,7 @@ jobs: startsWith(github.event.pull_request.title, '[chore] Release ') runs-on: ubuntu-latest + environment: Release permissions: # Used to create a short-lived OIDC token which is given to PyPi to identify this workflow job # See: https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#adding-permissions-settings @@ -102,11 +103,11 @@ jobs: steps: - name: Checkout source for publish - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # 4.3.1 # Download the artifacts created by the stage_release job. - name: Download release candidates - uses: actions/download-artifact@v4.1.7 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: dist path: dist @@ -124,13 +125,13 @@ jobs: --notes '${{ steps.preflight.outputs.changelog }}' - name: Publish to Pypi - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 # Post to Twitter if explicitly opted-in by adding the label 'release:tweet'. - name: Post to Twitter if: success() && contains(github.event.pull_request.labels.*.name, 'release:tweet') - uses: firebase/firebase-admin-node/.github/actions/send-tweet@main + uses: firebase/firebase-admin-node/.github/actions/send-tweet@2e2b36a84ba28679bcb7aecdacabfec0bded2d48 # Admin Node SDK v13.6.0 with: status: > ${{ steps.preflight.outputs.version }} of @Firebase Admin Python SDK is available.