> ## 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

export const GCell = ({children, className}) => <div className={`grid-table-cell ${className || ""}`} role="cell">
    {children}
  </div>;

export const GTH = ({children, className}) => <div className={`grid-table-th ${className || ""}`} role="columnheader">
    {children}
  </div>;

export const GRow = ({children}) => <div className="grid-table-row" role="row">{children}</div>;

export const GBody = ({children}) => <div className="grid-table-body" role="rowgroup">{children}</div>;

export const GHead = ({children}) => <div className="grid-table-head" role="rowgroup">{children}</div>;

export const GTable = ({children, className, cols}) => <div className={`grid-table not-prose overflow-hidden rounded-2xl ${className || ""}`} style={{
  "--grid-table-cols": cols
}} role="table">
    {children}
  </div>;

export const Comments = ({children}) => {
  return <div class="my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-zinc-500/20 bg-zinc-50/50 dark:border-zinc-500/30 dark:bg-zinc-500/10" data-callout-type="comments">
      <div class="w-4">
        <svg width="14" height="14" viewBox="0 0 640 640" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" aria-label="Comments">
            <path d="M320 112C434.9 112 528 205.1 528 320C528 434.9 434.9 528 320 528C205.1 528 112 434.9 112 320C112 205.1 205.1 112 320 112zM320 576C461.4 576 576 461.4 576 320C576 178.6 461.4 64 320 64C178.6 64 64 178.6 64 320C64 461.4 178.6 576 320 576zM280 400C266.7 400 256 410.7 256 424C256 437.3 266.7 448 280 448L360 448C373.3 448 384 437.3 384 424C384 410.7 373.3 400 360 400L352 400L352 312C352 298.7 341.3 288 328 288L280 288C266.7 288 256 298.7 256 312C256 325.3 266.7 336 280 336L304 336L304 400L280 400zM320 256C337.7 256 352 241.7 352 224C352 206.3 337.7 192 320 192C302.3 192 288 206.3 288 224C288 241.7 302.3 256 320 256z" />
        </svg>
      </div>
      <div class="text-sm prose min-w-0 w-full">
        {children}
      </div>
    </div>;
};

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?](/docs/platform/concepts/what-is-a-project)
* 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](/docs/platform/guides/security/programmatic-access-via-machine-users).
* 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.

<GTable cols="26% 31% 43%">
  <GHead>
    <GRow>
      <GTH>Decision</GTH>
      <GTH>Our choice</GTH>
      <GTH>Why</GTH>
    </GRow>
  </GHead>

  <GBody>
    <GRow>
      <GCell>What a merge to `main` means</GCell>
      <GCell>A production deployment <br /> created with `--production`</GCell>
      <GCell>`main` is the one version that runs in production and responds to events</GCell>
    </GRow>

    <GRow>
      <GCell>What a push to another branch means</GCell>
      <GCell>An isolated branch deployment created <br /> with `--branch`</GCell>
      <GCell>Run unreleased code on platform compute without affecting production or consuming production events</GCell>
    </GRow>

    <GRow>
      <GCell>How CI authenticates</GCell>
      <GCell>A machine user that authenticates with GitHub's OIDC token</GCell>
      <GCell>No long-lived credentials live in the repository, and the machine user's privileges limit what CI can touch</GCell>
    </GRow>

    <GRow>
      <GCell>When to tear down</GCell>
      <GCell>When the branch is deleted <br /> (never tears down `main`)</GCell>
      <GCell>Branch deployments accumulate, so removing them keeps the project list and the cluster clean</GCell>
    </GRow>
  </GBody>
</GTable>

## 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:

<GTable cols="25% 52% 23%">
  <GHead>
    <GRow>
      <GTH>Deployment flag</GTH>
      <GTH>Deployment name</GTH>
      <GTH>Type</GTH>
    </GRow>
  </GHead>

  <GBody>
    <GRow>
      <GCell>No flag</GCell>
      <GCell>`package_triage.user.<YOUR_EMAIL>.TriageFlow`</GCell>
      <GCell>Personal</GCell>
    </GRow>

    <GRow>
      <GCell>`--branch <BRANCH>`</GCell>
      <GCell>`package_triage.test.<BRANCH>.TriageFlow`</GCell>
      <GCell>Isolated branch</GCell>
    </GRow>

    <GRow>
      <GCell>`--production`</GCell>
      <GCell>`package_triage.prod.TriageFlow`</GCell>
      <GCell>Production</GCell>
    </GRow>
  </GBody>
</GTable>

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.

<Steps>
  <Step title="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.
    </Note>
  </Step>

  <Step title="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](/docs/platform/concepts/roles-and-privileges).
  </Step>
</Steps>

## 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.<br />
  Replace \<DEPLOYMENT\_DOMAIN> with the FQDN of your Anaconda Platform deployment.<br />
  Replace \<PERIMETER> with the perimeter that CI deploys into.
</Comments>

<Note>
  The `<MACHINE_USER_NAME>`, `<DEPLOYMENT_DOMAIN>`, and `<PERIMETER>` values must match the values you used in the deploy workflow.
</Note>

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`.
</Note>

## 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.<br />
  Replace \<DEPLOYMENT\_DOMAIN> with the FQDN of your Anaconda Platform deployment.<br />
  Replace \<PERIMETER> with the perimeter that CI deploys into.<br />
  Replace \<PROJECT\_NAME> with the project name from the `@project` decorator.
</Comments>

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](/docs/platform/guides/projects/project-lifecycle#promoting-assets-before-teardown).
</Warning>

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](/docs/platform/guides/deploy/deployment-notifications).
* Learn how to promote assets from a feature branch before teardown. For details, see [Project lifecycle](/docs/platform/guides/projects/project-lifecycle).
* Adapt the authentication pattern to other CI/CD systems. For details, see [CI/CD integration](/docs/platform/guides/deploy/cicd-integration).
