Skip to content
Closed
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f882e93
feat(cloud): Add CloudWorkspace.from_env() classmethod (#909)
aaronsteers Dec 11, 2025
60a087f
feat: Add `/prerelease` slash command for PyPI prereleases (#910)
aaronsteers Dec 13, 2025
3869ac4
fix: Add required permissions for prerelease workflow (#911)
aaronsteers Dec 14, 2025
cd1b390
fix: Use workflow_dispatch instead of workflow_call for OIDC compatib…
aaronsteers Dec 14, 2025
9f3c707
fix: Use GitHub App token for prerelease workflow dispatch (#913)
aaronsteers Dec 14, 2025
5591792
feat(mcp): Add HTTP header bearer token authentication support
devin-ai-integration[bot] Dec 16, 2025
954dd1a
fix(lint): Remove unused noqa directive in CloudWorkspace
devin-ai-integration[bot] Dec 16, 2025
ab593dd
refactor: Move imports to top-level per repo standards
devin-ai-integration[bot] Dec 16, 2025
5298ade
Merge remote-tracking branch 'origin/devin/1763010134-bearer-token-au…
devin-ai-integration[bot] Dec 16, 2025
59ad29a
fix(types): Fix type errors from base PR merge - pass bearer_token to…
devin-ai-integration[bot] Dec 16, 2025
3b08a87
Merge main to get noqa comments for SLF001 lint errors
devin-ai-integration[bot] Dec 16, 2025
4c0e44c
fix(lint): Add noqa comment for PLR0904 on CloudWorkspace class
devin-ai-integration[bot] Dec 16, 2025
dcde4aa
fix(lint): Update noqa comments to force merge conflict resolution in…
devin-ai-integration[bot] Dec 16, 2025
96ed8d4
Merge base PR branch and resolve conflicts in favor of noqa comments
devin-ai-integration[bot] Dec 16, 2025
d0215fd
refactor: Remove unrelated CI type fixes, keep only spec changes and …
devin-ai-integration[bot] Dec 16, 2025
98d6d26
chore: Remove internal task tracking file
devin-ai-integration[bot] Dec 16, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/pr-welcome-community.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ As needed or by request, Airbyte Maintainers can execute the following slash com
- `/fix-pr` - Fixes most formatting and linting issues
- `/poetry-lock` - Updates poetry.lock file
- `/test-pr` - Runs tests with the updated PyAirbyte
- `/prerelease` - Builds and publishes a prerelease version to PyPI

### Community Support

Expand Down
1 change: 1 addition & 0 deletions .github/pr-welcome-internal.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Airbyte Maintainers can execute the following slash commands on your PR:
- `/fix-pr` - Fixes most formatting and linting issues
- `/poetry-lock` - Updates poetry.lock file
- `/test-pr` - Runs tests with the updated PyAirbyte
- `/prerelease` - Builds and publishes a prerelease version to PyPI

### Community Support

Expand Down
136 changes: 136 additions & 0 deletions .github/workflows/prerelease-command.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
name: On-Demand Prerelease

on:
workflow_dispatch:
inputs:
pr:
description: 'PR Number'
type: string
required: true
comment-id:
description: 'Comment ID (Optional)'
type: string
required: false

env:
AIRBYTE_ANALYTICS_ID: ${{ vars.AIRBYTE_ANALYTICS_ID }}

permissions:
contents: write
id-token: write
pull-requests: write
issues: write

jobs:
resolve-pr:
name: Set up Workflow
runs-on: ubuntu-latest
steps:
- name: Resolve workflow variables
id: vars
uses: aaronsteers/resolve-ci-vars-action@2e56afab0344bbe03c047dfa39bae559d0291472 # v0.1.6

- name: Append comment with job run link
id: first-comment-action
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
with:
comment-id: ${{ github.event.inputs.comment-id }}
issue-number: ${{ github.event.inputs.pr }}
body: |
> **Prerelease Build Started**
>
> Building and publishing prerelease package from this PR...
> [Check job output.](${{ steps.vars.outputs.run-url }})
- name: Checkout to get latest tag
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0

- name: Compute prerelease version
id: version
run: |

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 [actionlint] reported by reviewdog 🐶
shellcheck reported issue in this script: SC2086:info:7:39: Double quote to prevent globbing and word splitting [shellcheck]

# Get the latest tag version (strip 'v' prefix if present)
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
BASE_VERSION=${LATEST_TAG#v}
# Create a unique prerelease version using PR number and run ID
# Format: {base}.dev{pr_number}{run_id} (e.g., 0.34.0.dev825123456789)
PRERELEASE_VERSION="${BASE_VERSION}.dev${{ github.event.inputs.pr }}${{ github.run_id }}"
echo "version=$PRERELEASE_VERSION" >> $GITHUB_OUTPUT
echo "Computed prerelease version: $PRERELEASE_VERSION"
outputs:
source-repo: ${{ steps.vars.outputs.pr-source-repo-name-full }}
source-branch: ${{ steps.vars.outputs.pr-source-git-branch }}
commit-sha: ${{ steps.vars.outputs.pr-source-git-sha }}
pr-number: ${{ steps.vars.outputs.pr-number }}
job-run-url: ${{ steps.vars.outputs.run-url }}
first-comment-id: ${{ steps.first-comment-action.outputs.comment-id }}
prerelease-version: ${{ steps.version.outputs.version }}

build-and-publish:
name: Trigger Publish Workflow
needs: [resolve-pr]
runs-on: ubuntu-latest
steps:
- name: Authenticate as GitHub App
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
id: get-app-token
with:
owner: "airbytehq"
repositories: "PyAirbyte"
app-id: ${{ secrets.OCTAVIA_BOT_APP_ID }}
private-key: ${{ secrets.OCTAVIA_BOT_PRIVATE_KEY }}
- name: Trigger pypi_publish workflow
id: dispatch
uses: the-actions-org/workflow-dispatch@v4
with:
workflow: pypi_publish.yml
token: ${{ steps.get-app-token.outputs.token }}
ref: main # Run from main so OIDC attestation matches trusted publisher
inputs: '{"git_ref": "refs/pull/${{ github.event.inputs.pr }}/head", "version_override": "${{ needs.resolve-pr.outputs.prerelease-version }}", "publish": "true"}'
wait-for-completion: true
wait-for-completion-timeout: 30m
outputs:
workflow-conclusion: ${{ steps.dispatch.outputs.workflow-conclusion }}
workflow-url: ${{ steps.dispatch.outputs.workflow-url }}

post-result-comment:
name: Write Status to PR
needs: [resolve-pr, build-and-publish]
if: always()
runs-on: ubuntu-latest
steps:
- name: Post success comment
if: needs.build-and-publish.result == 'success'
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
with:
comment-id: ${{ needs.resolve-pr.outputs.first-comment-id }}
issue-number: ${{ github.event.inputs.pr }}
reactions: rocket
body: |
> **Prerelease Published to PyPI**
>
> Version: `${{ needs.resolve-pr.outputs.prerelease-version }}`
> [View publish workflow](${{ needs.build-and-publish.outputs.workflow-url }})
>
> Install with:
> ```bash
> pip install airbyte==${{ needs.resolve-pr.outputs.prerelease-version }}
> ```
- name: Post failure comment
if: needs.build-and-publish.result == 'failure'
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
with:
comment-id: ${{ needs.resolve-pr.outputs.first-comment-id }}
issue-number: ${{ github.event.inputs.pr }}
reactions: confused
body: |
> **Prerelease Build/Publish Failed**
>
> The prerelease encountered an error.
> [Check publish workflow output](${{ needs.build-and-publish.outputs.workflow-url }}) for details.
>
> You can still install directly from this PR branch:
> ```bash
> pip install 'git+https://github.com/${{ needs.resolve-pr.outputs.source-repo }}.git@${{ needs.resolve-pr.outputs.source-branch }}'
> ```
50 changes: 48 additions & 2 deletions .github/workflows/pypi_publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,36 @@ on:
push:

workflow_dispatch:
inputs:
git_ref:
description: 'Git ref (SHA or branch) to checkout and build'
required: false
type: string
version_override:
description: 'Version to use (overrides dynamic versioning)'
required: false
type: string
publish:
description: 'Whether to publish to PyPI (true/false)'
required: false
type: string
default: 'false'

workflow_call:
inputs:
git_ref:
description: 'Git ref (SHA or branch) to checkout and build'
required: true
type: string
version_override:
description: 'Version to use (overrides dynamic versioning)'
required: false
type: string
publish:
description: 'Whether to publish to PyPI'
required: false
type: boolean
default: false

env:
AIRBYTE_ANALYTICS_ID: ${{ vars.AIRBYTE_ANALYTICS_ID }}
Expand All @@ -14,8 +44,21 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ inputs.git_ref || github.ref }}
fetch-depth: 0
- uses: hynek/build-and-inspect-python-package@efb823f52190ad02594531168b7a2d5790e66516 # v2.14.0
- name: Prepare version override
id: version
run: |

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 [actionlint] reported by reviewdog 🐶
shellcheck reported issue in this script: SC2086:info:1:51: Double quote to prevent globbing and word splitting [shellcheck]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 [actionlint] reported by reviewdog 🐶
shellcheck reported issue in this script: SC2086:info:2:61: Double quote to prevent globbing and word splitting [shellcheck]

echo "override=${{ inputs.version_override }}" >> $GITHUB_OUTPUT
echo "has_override=${{ inputs.version_override != '' }}" >> $GITHUB_OUTPUT
- name: Build package (with version override)
if: steps.version.outputs.has_override == 'true'
uses: hynek/build-and-inspect-python-package@efb823f52190ad02594531168b7a2d5790e66516 # v2.14.0
env:
POETRY_DYNAMIC_VERSIONING_BYPASS: ${{ steps.version.outputs.override }}
- name: Build package (dynamic version)
if: steps.version.outputs.has_override != 'true'
uses: hynek/build-and-inspect-python-package@efb823f52190ad02594531168b7a2d5790e66516 # v2.14.0

publish:
name: Publish to PyPI
Expand All @@ -27,13 +70,16 @@ jobs:
environment:
name: PyPi
url: https://pypi.org/p/airbyte
if: startsWith(github.ref, 'refs/tags/')
# Publish when: (1) triggered by a tag push, OR (2) called with publish=true (handles both boolean and string)
if: startsWith(github.ref, 'refs/tags/') || inputs.publish == true || inputs.publish == 'true'
steps:
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: Packages
path: dist
- name: Upload wheel to release
# Only upload to GitHub release when triggered by a tag
if: startsWith(github.ref, 'refs/tags/')
uses: svenstaro/upload-release-action@81c65b7cd4de9b2570615ce3aad67a41de5b1a13 # latest
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/slash_command_dispatch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ jobs:
fix-pr
test-pr
poetry-lock
prerelease
static-args: |
pr=${{ github.event.issue.number }}
comment-id=${{ github.event.comment.id }}
Expand Down
11 changes: 8 additions & 3 deletions airbyte/_util/api_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -1249,9 +1249,9 @@ def _make_config_api_request(
api_root: str,
path: str,
json: dict[str, Any],
client_id: SecretString,
client_secret: SecretString,
bearer_token: SecretString,
client_id: SecretString | None,
client_secret: SecretString | None,
bearer_token: SecretString | None,
) -> dict[str, Any]:
config_api_root = get_config_api_root(api_root)
if client_id and client_secret and not bearer_token:
Expand All @@ -1260,6 +1260,11 @@ def _make_config_api_request(
client_secret=client_secret,
api_root=api_root,
)
if not bearer_token:
raise PyAirbyteInputError(
message="No authentication provided. Either bearer_token or both client_id and "
"client_secret must be provided.",
)
headers: dict[str, Any] = {
"Content-Type": "application/json",
"Authorization": f"Bearer {bearer_token}",
Expand Down
20 changes: 18 additions & 2 deletions airbyte/cloud/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"""Authentication-related constants and utilities for the Airbyte Cloud."""

from airbyte import constants
from airbyte.mcp._util import get_bearer_token_from_headers
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin, Can you create a follow-on issue to refactor slightly...

Ideally nothing outside the MCP module would import from the MCP module. That said, this is expedient and doesn't seem to create circular dependencies or side effects (that I can see). So I'm inclined to merge and create follow-on issue for refactoring.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created follow-on issue: #915

The issue proposes moving get_bearer_token_from_headers() to a shared utility location (like airbyte/_util/http_auth.py) so the MCP module can import from it rather than the other way around.

from airbyte.secrets import SecretString
from airbyte.secrets.util import get_secret, try_get_secret

Expand All @@ -26,8 +27,23 @@ def resolve_cloud_bearer_token(
input_value: str | SecretString | None = None,
/,
) -> SecretString | None:
"""Get the Airbyte Cloud bearer token from the environment."""
return try_get_secret(constants.CLOUD_BEARER_TOKEN_ENV_VAR, default=input_value)
"""Get the Airbyte Cloud bearer token.

Resolution order:
1. Explicit input_value parameter
2. HTTP Authorization header (when running as MCP HTTP server)
3. AIRBYTE_CLOUD_BEARER_TOKEN environment variable
"""
if input_value:
return SecretString(input_value)

# Try HTTP header first (for MCP HTTP transport)
header_token = get_bearer_token_from_headers()
if header_token:
return SecretString(header_token)

# Fall back to environment variable
return try_get_secret(constants.CLOUD_BEARER_TOKEN_ENV_VAR, default=None)


def resolve_cloud_api_url(
Expand Down
2 changes: 2 additions & 0 deletions airbyte/cloud/connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ def get_state_artifacts(self) -> list[dict[str, Any]] | None:
api_root=self.workspace.api_root,
client_id=self.workspace.client_id,
client_secret=self.workspace.client_secret,
bearer_token=self.workspace.bearer_token,
)
if state_response.get("stateType") == "not_set":
return None
Expand All @@ -322,6 +323,7 @@ def get_catalog_artifact(self) -> dict[str, Any] | None:
api_root=self.workspace.api_root,
client_id=self.workspace.client_id,
client_secret=self.workspace.client_secret,
bearer_token=self.workspace.bearer_token,
)
return connection_response.get("syncCatalog")

Expand Down
1 change: 1 addition & 0 deletions airbyte/cloud/connectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,7 @@ def set_testing_values(
api_root=self.workspace.api_root,
client_id=self.workspace.client_id,
client_secret=self.workspace.client_secret,
bearer_token=self.workspace.bearer_token,
)

return self
56 changes: 56 additions & 0 deletions airbyte/cloud/workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@
from airbyte import exceptions as exc
from airbyte._util import api_util, text_util
from airbyte._util.api_util import get_web_url_root
from airbyte.cloud.auth import (
resolve_cloud_api_url,
resolve_cloud_client_id,
resolve_cloud_client_secret,
resolve_cloud_workspace_id,
)
from airbyte.cloud.connections import CloudConnection
from airbyte.cloud.connectors import (
CloudDestination,
Expand Down Expand Up @@ -148,6 +154,55 @@ def __post_init__(self) -> None:
if self.bearer_token is not None:
self.bearer_token = SecretString(self.bearer_token)

@classmethod
def from_env(
cls,
workspace_id: str | None = None,
*,
api_root: str | None = None,
) -> CloudWorkspace:
"""Create a CloudWorkspace using credentials from environment variables.

This factory method resolves credentials from environment variables,
providing a convenient way to create a workspace without explicitly
passing credentials.

Environment variables used:
- `AIRBYTE_CLOUD_CLIENT_ID`: Required. The OAuth client ID.
- `AIRBYTE_CLOUD_CLIENT_SECRET`: Required. The OAuth client secret.
- `AIRBYTE_CLOUD_WORKSPACE_ID`: The workspace ID (if not passed as argument).
- `AIRBYTE_CLOUD_API_URL`: Optional. The API root URL (defaults to Airbyte Cloud).

Args:
workspace_id: The workspace ID. If not provided, will be resolved from
the `AIRBYTE_CLOUD_WORKSPACE_ID` environment variable.
api_root: The API root URL. If not provided, will be resolved from
the `AIRBYTE_CLOUD_API_URL` environment variable, or default to
the Airbyte Cloud API.

Returns:
A CloudWorkspace instance configured with credentials from the environment.

Raises:
PyAirbyteSecretNotFoundError: If required credentials are not found in
the environment.

Example:
```python
# With workspace_id from environment
workspace = CloudWorkspace.from_env()

# With explicit workspace_id
workspace = CloudWorkspace.from_env(workspace_id="your-workspace-id")
```
"""
return cls(
workspace_id=resolve_cloud_workspace_id(workspace_id),
client_id=resolve_cloud_client_id(),
client_secret=resolve_cloud_client_secret(),
api_root=resolve_cloud_api_url(api_root),
)

@property
def workspace_url(self) -> str | None:
"""The web URL of the workspace."""
Expand All @@ -165,6 +220,7 @@ def _organization_info(self) -> dict[str, Any]:
api_root=self.api_root,
client_id=self.client_id,
client_secret=self.client_secret,
bearer_token=self.bearer_token,
)

@overload
Expand Down
Loading
Loading