Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
69 changes: 61 additions & 8 deletions .github/workflows/deploy-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,40 @@ jobs:
outputs:
rc_version: ${{ steps.compute.outputs.rc_version }}
steps:
- name: Validate deployment app configuration
env:
DEPLOY_APP_CLIENT_ID: ${{ vars.DEPLOY_APP_CLIENT_ID }}
DEPLOY_APP_PRIVATE_KEY: ${{ secrets.DEPLOY_APP_PRIVATE_KEY }}
run: |
MISSING=()
[[ -z "$DEPLOY_APP_CLIENT_ID" ]] && MISSING+=(DEPLOY_APP_CLIENT_ID)
[[ -z "$DEPLOY_APP_PRIVATE_KEY" ]] && MISSING+=(DEPLOY_APP_PRIVATE_KEY)
if [[ ${#MISSING[@]} -gt 0 ]]; then
echo "::error::Deployment app configuration is missing: ${MISSING[*]}. See docs/github-app-deployment.md."
exit 1
fi

# Validate the installation and permissions before running tests or
# publishing. These tokens are revoked when this job finishes; each
# later job creates a fresh token immediately before it needs one.
- name: Verify schema repository app access
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
client-id: ${{ vars.DEPLOY_APP_CLIENT_ID }}
private-key: ${{ secrets.DEPLOY_APP_PRIVATE_KEY }}
owner: wrangleworks
repositories: wrangleworks.github.io
permission-contents: write

- name: Verify Lambda workflow app access
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
client-id: ${{ vars.DEPLOY_APP_CLIENT_ID }}
private-key: ${{ secrets.DEPLOY_APP_PRIVATE_KEY }}
owner: wrangleworks
repositories: Lambda-Recipes
permission-actions: write

- name: Validate version input format
run: |
if ! echo "${{ inputs.version }}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
Expand Down Expand Up @@ -151,11 +185,21 @@ jobs:
- name: Save schema as schema_dev.json
run: cp schema/schema.json schema/schema_dev.json

- name: Create schema publishing token
id: schema-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
client-id: ${{ vars.DEPLOY_APP_CLIENT_ID }}
private-key: ${{ secrets.DEPLOY_APP_PRIVATE_KEY }}
owner: wrangleworks
repositories: wrangleworks.github.io
permission-contents: write

- name: Checkout wrangleworks.github.io
uses: actions/checkout@v5
with:
repository: wrangleworks/wrangleworks.github.io
token: ${{ secrets.CROSS_REPO_PAT_V2 }}
token: ${{ steps.schema-token.outputs.token }}
path: wrangleworks.github.io

- name: Copy schema_dev.json to wrangleworks.github.io
Expand All @@ -164,8 +208,8 @@ jobs:
- name: Commit and push schema_dev.json
working-directory: wrangleworks.github.io
run: |
git config user.name "mborodii-prog"
git config user.email "mborodii@binariks.com"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add schema/recipes/schema_dev.json
if git diff --cached --quiet; then
echo "No changes to schema_dev.json, skipping commit."
Expand Down Expand Up @@ -228,9 +272,8 @@ jobs:
run: twine upload --repository codeartifact dist/*

# ── 5. Trigger DEV deployment in Lambda-Recipes ───────────────────────────
# CROSS_REPO_PAT_V2: a GitHub PAT with Actions read/write on Lambda-Recipes
# (and Contents read/write on wrangleworks.github.io for schema publish),
# stored as a repository or org secret. Required to dispatch workflows across repositories.
# The deployment app issues a short-lived token limited to Lambda-Recipes
# workflow access. Installation and configuration: docs/github-app-deployment.md.
trigger-deploy-dev:
name: Trigger DEV Deploy
runs-on: ubuntu-latest
Expand Down Expand Up @@ -266,16 +309,26 @@ jobs:
'{wrangles_version: $wrangles_version, deploy_user: $deploy_user, reason: $reason}')
echo "payload=$payload" >> "$GITHUB_OUTPUT"

- name: Create Lambda deployment token
id: deploy-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
client-id: ${{ vars.DEPLOY_APP_CLIENT_ID }}
private-key: ${{ secrets.DEPLOY_APP_PRIVATE_KEY }}
owner: wrangleworks
repositories: Lambda-Recipes
permission-actions: write

- name: Deploy Dev
uses: convictional/trigger-workflow-and-wait@f69fa9eedd3c62a599220f4d5745230e237904be # v1.6.5
with:
owner: wrangleworks
repo: Lambda-Recipes
workflow_file_name: deploy-dev.yml
github_token: ${{ secrets.CROSS_REPO_PAT_V2 }}
github_token: ${{ steps.deploy-token.outputs.token }}
ref: main
wait_interval: 15
client_payload: ${{ steps.dispatch.outputs.payload }}
propagate_failure: true
trigger_workflow: true
wait_workflow: true
wait_workflow: true
41 changes: 37 additions & 4 deletions .github/workflows/publish-tagged.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,30 @@ jobs:
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Validate deployment app configuration
env:
DEPLOY_APP_CLIENT_ID: ${{ vars.DEPLOY_APP_CLIENT_ID }}
DEPLOY_APP_PRIVATE_KEY: ${{ secrets.DEPLOY_APP_PRIVATE_KEY }}
run: |
MISSING=()
[[ -z "$DEPLOY_APP_CLIENT_ID" ]] && MISSING+=(DEPLOY_APP_CLIENT_ID)
[[ -z "$DEPLOY_APP_PRIVATE_KEY" ]] && MISSING+=(DEPLOY_APP_PRIVATE_KEY)
if [[ ${#MISSING[@]} -gt 0 ]]; then
echo "::error::Deployment app configuration is missing: ${MISSING[*]}. See docs/github-app-deployment.md."
exit 1
fi

# Check credentials and installation permissions before release tests
# or publishing. The dispatch job creates its own fresh token later.
- name: Verify Lambda workflow app access
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
client-id: ${{ vars.DEPLOY_APP_CLIENT_ID }}
private-key: ${{ secrets.DEPLOY_APP_PRIVATE_KEY }}
owner: wrangleworks
repositories: Lambda-Recipes
permission-actions: write

- name: Checkout Repository
uses: actions/checkout@v5

Expand Down Expand Up @@ -392,8 +416,8 @@ jobs:
# ── Trigger PROD deployment in Lambda-Recipes ─────────────────────────────
# Runs after publish-pypi (or when skipped) because Lambda-Recipes verify-setup
# requires the version to exist on PyPI before the deploy pipeline starts.
# CROSS_REPO_PAT_V2: GitHub PAT with Actions read/write on Lambda-Recipes
# (and Contents read/write on wrangleworks.github.io for Deploy Dev schema publish).
# The deployment app issues a short-lived token limited to Lambda-Recipes
# workflow access. Installation and configuration: docs/github-app-deployment.md.
trigger-deploy-prod:
name: Trigger PROD Deploy
runs-on: ubuntu-latest
Expand Down Expand Up @@ -430,17 +454,26 @@ jobs:
'{wrangles_version: $wrangles_version, deploy_user: $deploy_user}')
echo "payload=$payload" >> "$GITHUB_OUTPUT"

- name: Create Lambda deployment token
id: deploy-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
client-id: ${{ vars.DEPLOY_APP_CLIENT_ID }}
private-key: ${{ secrets.DEPLOY_APP_PRIVATE_KEY }}
owner: wrangleworks
repositories: Lambda-Recipes
permission-actions: write

- name: Deploy Prod
uses: convictional/trigger-workflow-and-wait@f69fa9eedd3c62a599220f4d5745230e237904be # v1.6.5
with:
owner: wrangleworks
repo: Lambda-Recipes
workflow_file_name: deploy-prod.yml
github_token: ${{ secrets.CROSS_REPO_PAT_V2 }}
github_token: ${{ steps.deploy-token.outputs.token }}
ref: main
wait_interval: 15
client_payload: ${{ steps.dispatch.outputs.payload }}
propagate_failure: true
trigger_workflow: true
wait_workflow: true

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ env.bak/
venv.bak/
docs/*
!docs/extract_ai_user_guide.md
!docs/github-app-deployment.md
.DS_Store

# Spyder project settings
Expand Down
98 changes: 98 additions & 0 deletions docs/github-app-deployment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# GitHub App authentication for deployments

The DEV and PROD deployment workflows use an organization-owned GitHub App for
operations in other repositories. Normal WranglesPY CI uses `GITHUB_TOKEN` for
checkout and GHCR publishing. AWS publishing and Lambda updates continue to use
GitHub OIDC. No personal access token is required by these deployment workflows.

The registered organization app is
[Wrangleworks Deployments](https://github.com/organizations/wrangleworks/settings/apps/wrangleworks-deployments).
Its [installation](https://github.com/organizations/wrangleworks/settings/installations/160195536)
is restricted to `wrangleworks.github.io` and `Lambda-Recipes`. Reuse this app for
the workflow configuration below.

## Register and install the app

An administrator of `wrangleworks` performs this setup once:

1. In the organization's developer settings, register a private GitHub App, for
example **Wrangleworks Deployments**. Set its homepage to
`https://github.com/wrangleworks/WranglesPY` and allow installation only on the
`wrangleworks` account. Disable webhooks; no callback URL, user authorization,
or event subscriptions are needed.
2. Grant these **repository permissions**: **Contents: Read and write** and
**Actions: Read and write**. GitHub includes **Metadata: Read-only**. Leave
other repository, organization, and account permissions unset.
3. Install the app on **Only select repositories** and select exactly
`wrangleworks.github.io` and `Lambda-Recipes`. It does not need installation
on WranglesPY to issue tokens for those two repositories.
4. Copy the app's **Client ID** into the WranglesPY Actions repository variable
`DEPLOY_APP_CLIENT_ID`. Use the Client ID, not the numeric App ID.
5. Generate an app private key and store the complete PEM, including its header
and footer, as the WranglesPY Actions repository secret
`DEPLOY_APP_PRIVATE_KEY`. Handle the key through GitHub's secret interface;
never paste it into a recipe, issue, log, workflow file, or committed file.
Do not put an installation token in this secret.

Organization-scoped configuration may be used instead, provided both names are
available only to the repositories that need the app credentials. A repository
variable or secret with the same name takes precedence over organization
configuration, so remove stale overrides when moving configuration.

## Token scope and lifetime

The app's registration permissions are its maximum access across the two
installed repositories. Each workflow requests a narrower installation token:

| Operation | Token repository | Token permission |
| --- | --- | --- |
| Publish `schema/recipes/schema_dev.json` | `wrangleworks.github.io` | Contents: write |
| Dispatch and wait for DEV or PROD deployment | `Lambda-Recipes` | Actions: write |

The first DEV job validates both repository scopes; the first PROD job validates
the Lambda scope. Missing configuration, invalid app credentials, or insufficient
installation permissions stop the workflow before tests and package publishing.
These validation steps create tokens but do not write to either repository or
start a deployment. Repository branch rules can still reject a later schema push.

Each publishing or dispatch job creates a fresh token immediately before use.
Tokens are limited to one repository and automatically revoked when their job
finishes; GitHub installation tokens also expire after one hour. The pinned
`actions/create-github-app-token` action handles generation, masking, and
revocation. Tokens are not passed between jobs or retained as secrets.

Schema commits use `github-actions[bot]` as the author. This is commit attribution;
the app installation token supplies the actual authorization. Deployment payloads
continue to record the original initiating user through `github.actor`.

## Validate the migration in DEV

1. Configure the app, installation, variable, and secret before running the
updated deployment workflow. Merge the workflow changes to `main` first.
2. Run **Deploy Dev** from `main` with the intended base version. Record the
workflow's commit SHA and resulting RC version. An old failed run retains its
old workflow definition; rerunning it will not apply this migration.
3. Verify the early app checks, schema publication, RC publication, and downstream
Lambda-Recipes DEV workflow. Confirm its image tag, image digest, and
`execute-recipe-dev` update result before calling the deployment complete.
DEV validation does not require dispatching the PROD workflow.

The existing test and publishing gates, downstream `main` refs, and AWS roles
remain in place. GHCR is the WranglesPY CI/test image; Lambda-Recipes builds the
image deployed to `execute-recipe-dev`.

The workflows contain no fallback to `CROSS_REPO_PAT_V2`. After successful DEV
validation, an administrator may retire that old secret once its remaining
consumers elsewhere have been checked. Do not remove shared credentials merely
because WranglesPY no longer references them.

For key rotation, create a replacement key on the same app, update
`DEPLOY_APP_PRIVATE_KEY`, validate the app checks, and then revoke the old key.
The app and Client ID can remain unchanged.

## References

- [GitHub App authentication in Actions](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/making-authenticated-api-requests-with-a-github-app-in-a-github-actions-workflow)
- [Installation token permissions and expiration](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-an-installation-access-token-for-a-github-app)
- [Pinned token action and inputs](https://github.com/actions/create-github-app-token/blob/bcd2ba49218906704ab6c1aa796996da409d3eb1/action.yml)
- [Repository scope of GITHUB_TOKEN](https://docs.github.com/en/actions/concepts/security/github_token)
67 changes: 47 additions & 20 deletions tests/connectors/test_train.py
Original file line number Diff line number Diff line change
Expand Up @@ -1028,26 +1028,53 @@ def test_insert_duplicate_keys(self):
wrangles.recipe.run(recipe, dataframe=df)


def test_update_model_not_found(self):
"""
Test update fails when model doesn't exist
"""
df = pd.DataFrame({
'Key': ['Rachel', 'Dolores'],
'Value': ['Blade Runner 2049', 'Westworld Updated']
})

recipe = """
write:
- train.lookup:
model_id: test-model-id
action: UPDATE
"""

# This would test with an actual existing model
# For testing purposes, we'll catch the expected error
with pytest.raises(RuntimeError, match="Access denied to model test-model-id"):
wrangles.recipe.run(recipe, dataframe=df)
@pytest.mark.parametrize(
"status_code, error_type, message",
[
pytest.param(
404,
RuntimeError,
"Something went wrong trying to access model test-model-id",
id="missing-model",
),
pytest.param(
403,
wrangles.data.AuthorizationError,
"Access denied to model test-model-id. Check the user's model permissions.",
id="access-denied",
),
],
)
def test_update_model_access_errors(self, mocker, status_code, error_type, message):
"""Missing models return 404; existing models without access return 403."""
response = _requests.Response()
response.status_code = status_code
mocker.patch("wrangles.data._auth.get_access_token", return_value="test-token")
request = mocker.patch("wrangles.data._utils.request_retries", return_value=response)
train_lookup = mocker.patch("wrangles.connectors.train._train.lookup")

df = pd.DataFrame({
'Key': ['Rachel', 'Dolores'],
'Value': ['Blade Runner 2049', 'Westworld Updated'],
})
recipe = """
write:
- train.lookup:
model_id: test-model-id
action: UPDATE
"""

with pytest.raises(error_type) as error:
wrangles.recipe.run(recipe, dataframe=df)

assert type(error.value) is error_type
assert str(error.value) == f"train.lookup (line 3) - {message}"
assert request.called
for call in request.call_args_list:
assert call.kwargs["request_type"] == "GET"
assert call.kwargs["url"].endswith("/model/metadata")
assert call.kwargs["params"] == {"id": "test-model-id"}
train_lookup.assert_not_called()

def test_action_parameter_validation_recipe(self):
"""
Expand Down
Loading