> ## Documentation Index
> Fetch the complete documentation index at: https://anaconda.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Deploy flows per branch from GitHub Actions

This tutorial demonstrates how to connect GitHub Actions to a Workflow on Anaconda Platform so that every push automatically deploys the flow.

Using this pattern, merging to `main` updates the production deployment. Every other branch gets an isolated deployment to test against real platform compute, and deleting a branch tears its deployment down. Anaconda uses this pattern for the flow behind its package request triage intake agent, which assesses whether an open-source package is safe to add to the `main` channel.

By the end of this tutorial, you will have:

* A flow that deploys to production from `main` and to an isolated branch deployment from every other branch
* A GitHub Actions workflow that deploys on push and on pull request
* A GitHub Actions workflow that tears down a branch deployment when its branch is deleted

## Prerequisites

* A flow you are ready to deploy to Anaconda Platform. The flow must use the `@project` decorator. For background, see [What is a project?](https://anaconda.com/docs/platform/concepts/what-is-a-project.md)
* A GitHub repository with Actions enabled.
* A machine user that's connected to your github repo, or admin access to create one. For details, see [Programmatic access with machine users](https://anaconda.com/docs/platform/guides/security/programmatic-access-via-machine-users.md).
* The domain of your Anaconda Platform deployment and the name of the perimeter that CI deploys into.

## Decide how your branches map to Workflows

How you choose to map branches to Workflows depends on your branching strategy, constraints, and goals. This topic guides you through one method, with the reasoning explained below. If your constraints and goals differ, your GitHub Actions will likely need to look different from the ones shown here.

Decision

Our choice

Why

What a merge to `main` means

A production deployment \
created with `--production`

`main` is the one version that runs in production and responds to events

What a push to another branch means

An isolated branch deployment created \
with `--branch`

Run unreleased code on platform compute without affecting production or consuming production events

How CI authenticates

A machine user that authenticates with GitHub's OIDC token

No long-lived credentials live in the repository, and the machine user's privileges limit what CI can touch

When to tear down

When the branch is deleted \
(never tears down `main`)

Branch deployments accumulate, so removing them keeps the project list and the cluster clean

## Name the project and scope the trigger

Give the flow a project name with `@project`. If the flow responds to platform events, arm its trigger only in production. Branch deployments share the project, so a trigger armed on every branch would make each branch consume production events.

```python expandable highlight={12} theme={null}
import sys

from metaflow import FlowSpec, Parameter, project, step, trigger


def production_trigger(event):
    if "--production" not in sys.argv:
        return lambda cls: cls
    return trigger(event=event)


@project(name="package_triage")
@production_trigger("new-triage-ticket")
class TriageFlow(FlowSpec):
    ticket = Parameter("ticket", help="The ticket to triage.")

    @step
    def start(self):
        print(f"Triaging {self.ticket}")
        self.next(self.end)

    @step
    def end(self):
        pass


if __name__ == "__main__":
    TriageFlow()
```

Deploy the flow with `python flow.py argo-workflows create`. The `@project` decorator namespaces every deployment under the project name, and the flag you pass before `argo-workflows create` determines the deployment's name and type:

Deployment flag

Deployment name

Type

No flag

`package_triage.user.<YOUR_EMAIL>.TriageFlow`

Personal

`--branch <BRANCH>`

`package_triage.test.<BRANCH>.TriageFlow`

Isolated branch

`--production`

`package_triage.prod.TriageFlow`

Production

Only the `--production` deployment arms `@trigger`, so the `new-triage-ticket` event has exactly one consumer.

## Create a machine user for CI

CI authenticates as a machine user rather than a person. Create one scoped to the repository and to the perimeter CI deploys into.

**1. Create the machine user**

1. Select  **Users** in the left-hand navigation, then select the **Machines** tab.
2. Click  **Create New**.
3. Select **GitHub Actions**, then enter a **Name** and **Description**.
4. In **Attributes to validate**, set **Organization** and **Repository** to the repository that runs the workflow. Leave **Branch** empty so jobs on every branch can authenticate.
5. Click **Submit**.

> [!NOTE]
>
> Setting **Branch** to `main` scopes the machine user to that branch, so jobs on other branches fail to authenticate.

**2. Grant privileges in the target perimeter**

Open the machine user and grant it the **Execute** privilege in the perimeter CI deploys into. Privileges are per perimeter, so a machine user with privileges only in the `default` perimeter cannot deploy into another perimeter. For details, see [Roles and privileges](https://anaconda.com/docs/platform/concepts/roles-and-privileges.md).

## Deploy on every push

Create `.github/workflows/deploy.yml`. This workflow deploys on every push to `main`, on every pull request, and on demand. It deploys to production when the ref is `main`, and to an isolated branch deployment otherwise.

```yaml .github/workflows/deploy.yml expandable theme={null}
name: deploy-flow

on:
  push:
    branches: [main]
  pull_request:
  workflow_dispatch: {}

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: true

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    name: Deploy flow
    if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.13"

      - name: Install Outerbounds
        run: pip install outerbounds

      - name: Configure the machine user with OIDC
        run: |
          outerbounds service-principal-configure \
            --name <MACHINE_USER_NAME> \
            --deployment-domain <DEPLOYMENT_DOMAIN> \
            --perimeter <PERIMETER> \
            --github-actions

      - name: Deploy the flow
        # env:
        #   SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
        run: |
          set -euo pipefail
          ref="${GITHUB_REF_NAME}"
          if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
            ref="${GITHUB_HEAD_REF}"
          fi
          if [ "$ref" = "main" ]; then
            target=(--production)
          else
            branch=$(printf '%s' "$ref" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_]/_/g')
            target=(--branch "$branch")
          fi
          python3 flow.py \
            --environment=fast-bakery \
            --package-suffixes=.py,.yaml,.yml,.json \
            "${target[@]}" \
            argo-workflows create
            # To notify on failure, create a SLACK_WEBHOOK_URL repository
            # secret, uncomment the env block above, and add these flags:
            # --notify-on-error \
            # --notify-slack-webhook-url "$SLACK_WEBHOOK_URL"
```

Comments

Replace \<MACHINE\_USER\_NAME> with the name of the machine user you created.\\

Replace \<DEPLOYMENT\_DOMAIN> with the FQDN of your Anaconda Platform deployment.\\

Replace \<PERIMETER> with the perimeter that CI deploys into.

> [!NOTE]
>
> The `<MACHINE_USER_NAME>`, `<DEPLOYMENT_DOMAIN>`, and `<PERIMETER>` values must match the values you used in the deploy workflow.

The workflow makes these decisions explicit:

* **Triggers**: pushes to `main`, pull requests, and manual runs. Because only `branches` is defined under `push`, tag pushes do not trigger a deploy.
* **Concurrency**: `cancel-in-progress: true` keeps one deploy per ref running, so a fast series of pushes does not race. The most recent push wins and the earlier one is canceled.
* **Permissions**: `id-token: write` lets the job request GitHub's OIDC token, and `contents: read` lets it check out the repository.
* **Fork guard**: the `if` condition skips pull requests from forks. A fork's OIDC token does not carry the repository claims the machine user validates, so those jobs cannot authenticate.
* **Ref to target**: the job resolves the branch from `GITHUB_REF_NAME`, or from `GITHUB_HEAD_REF` for pull requests, and deploys with `--production` for `main` and `--branch` for everything else.
* **Branch normalization**: GitHub branch names can contain `/` and uppercase characters, which are not valid in a Metaflow branch name. The workflow lowercases the ref and replaces every character outside `a-z`, `0-9`, and `_` with `_`, so `Feature/Add-Scoring` becomes `feature_add_scoring`. Use the same normalization in the teardown workflow so both operate on the same branch name.
* **Authentication**: `service-principal-configure --github-actions` reads GitHub's OIDC token from the job environment and exchanges it for platform credentials. No long-lived platform credentials are stored in the repository.

The deploy command passes several options before `argo-workflows create`:

* `--environment=fast-bakery` builds a reproducible container from the flow's declared dependencies.
* `--package-suffixes=.py,.yaml,.yml,.json` packages non-Python files the flow reads at runtime, such as a config file.
* `--production` or `--branch` selects the deployment target.

> [!NOTE]
>
> `--environment`, `--package-suffixes`, and `--production` or `--branch` are top-level Metaflow options. They must come before `argo-workflows create`.

## Tear down a branch deployment

Create `.github/workflows/teardown-branch.yml`. This workflow runs when a branch is deleted and removes that branch's deployment.

```yaml .github/workflows/teardown-branch.yml expandable theme={null}
name: teardown-branch

on:
  delete:

concurrency:
  group: teardown-${{ github.event.ref }}
  cancel-in-progress: false

permissions:
  id-token: write

jobs:
  teardown:
    name: Tear down a branch deployment
    if: github.event.ref_type == 'branch' && github.event.ref != 'main'
    runs-on: ubuntu-latest
    steps:
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.13"

      - name: Install Outerbounds
        run: pip install outerbounds

      - name: Configure the machine user with OIDC
        run: |
          outerbounds service-principal-configure \
            --name <MACHINE_USER_NAME> \
            --deployment-domain <DEPLOYMENT_DOMAIN> \
            --perimeter <PERIMETER> \
            --github-actions

      - name: Tear down the branch deployment
        env:
          DELETED_REF: ${{ github.event.ref }}
        run: |
          set -euo pipefail
          ref="${DELETED_REF#refs/heads/}"
          branch=$(printf '%s' "$ref" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_]/_/g')
          case "$branch" in
            ""|main|master)
              echo "refusing to tear down '$branch' (deleted ref: ${ref})"
              exit 1
              ;;
          esac
          outerbounds flowproject teardown-branch --id "<PROJECT_NAME>/${branch}" --yes
```

Comments

Replace \<MACHINE\_USER\_NAME> with the name of the machine user you created.\\

Replace \<DEPLOYMENT\_DOMAIN> with the FQDN of your Anaconda Platform deployment.\\

Replace \<PERIMETER> with the perimeter that CI deploys into.\\

Replace \<PROJECT\_NAME> with the project name from the `@project` decorator.

The workflow makes these decisions explicit:

* **Trigger**: the `delete` event, which fires for branch and tag deletions. The `delete` event does not support branch filters, so the job guards against tearing down production itself. The `if` condition runs the job only for branch deletions other than `main`, so tag deletions and `main` are skipped.
* **Ref to branch**: the `delete` event reports the branch name in `github.event.ref`, such as `feature/add-scoring`. The workflow strips a `refs/heads/` prefix defensively and applies the same normalization as the deploy workflow.
* **Safety check**: the `case` statement is a second guard that refuses to run for an empty branch name, `main`, or `master`.
* **Teardown**: `flowproject teardown-branch --id "<PROJECT_NAME>/<BRANCH>" --yes` removes the branch deployment. The `--yes` flag skips the confirmation prompt, which CI cannot answer.

> [!WARNING]
>
> Teardown deletes asset metadata, not the underlying data. To keep an asset that a feature branch produced, promote it before the branch is torn down. For details, see [Project lifecycle](https://anaconda.com/docs/platform/guides/projects/project-lifecycle.md#promoting-assets-before-teardown).

To preview a teardown before it runs, use `--dry-run`:

```sh theme={null}
outerbounds flowproject teardown-branch --id package_triage/feature_add_scoring --dry-run
```

## Verify the loop

Confirm each path of the loop works:

1. Push a commit to a new branch. When the deploy workflow finishes, open the  **Workflows** view. A `package_triage.test.<BRANCH>.TriageFlow` deployment appears.
2. Merge the branch to `main`. The deploy workflow updates `package_triage.prod.TriageFlow`.
3. Delete the branch. The teardown workflow runs, and the branch deployment disappears from the  **Workflows** view.

## Next steps

To build on this tutorial:

* Add success and failure notifications. For details, see [Setting up notifications for deployments](https://anaconda.com/docs/platform/guides/deploy/deployment-notifications.md).
* Learn how to promote assets from a feature branch before teardown. For details, see [Project lifecycle](https://anaconda.com/docs/platform/guides/projects/project-lifecycle.md).
* Adapt the authentication pattern to other CI/CD systems. For details, see [CI/CD integration](https://anaconda.com/docs/platform/guides/deploy/cicd-integration.md).
