Skip to main content
Teams

From Azure DevOps to GitHub Actions: migrating your Teams app CI/CD pipeline

From Azure DevOps to GitHub Actions: migrating your Teams app CI/CD pipeline

Back in 2020 I wrote a post about setting up CI/CD for Teams apps with Azure DevOps. That post covered the full flow: build your bot, package the manifest, deploy to Azure, and push the Teams app to your tenant catalog. It worked great and I’ve been running variations of that pipeline ever since. But things have changed. Since February 2025, the Basic Azure DevOps license is free if you have GitHub Enterprise Cloud. Read that again. Microsoft isn’t charging separately for Azure DevOps anymore if you’re already paying for GitHub. The direction couldn’t be more clear.

Now, I’m not saying Azure DevOps is dead. It’s still running massive enterprise workloads and it will be around for years. But if you’re starting a new Teams app project today and you’re picking between the two? The writing is on the wall.

Why move now

Feature investment is shifting to GitHub. Just look at what’s happened in the last year. The M365 Agents Toolkit (formerly Teams Toolkit) supports GitHub Actions natively with scaffolded workflows. Copilot for Pull Requests lives in GitHub. The new generation of Microsoft’s developer tooling is GitHub-first, Azure DevOps-second. I’ve watched this pattern before with other Microsoft products and it always goes the same way. The new hotness gets the features, the old reliable gets maintenance updates.

Of course, you could wait. But the longer you wait, the more pipeline logic you accumulate in Azure DevOps that you’ll eventually need to move anyway. I decided to rip the band-aid off with one of my real Teams bot projects. Let’s get started.

The migration

If you remember the original post, I had a YAML build pipeline that compiled the .NET project, ran tests, and zipped up the Teams manifest. Then I had a classic release pipeline with two stages: one to deploy the bot to Azure App Service, and another to push the Teams app package to the tenant catalog using the O365 CLI. Two separate things, configured in two separate places.

With GitHub Actions, the whole thing lives in a single workflow file. Here’s what I ended up with:

name: Teams App CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: "8.0.x"

      - name: Restore and build
        run: dotnet build --configuration Release

      - name: Run tests
        run: dotnet test --no-build --configuration Release

      - name: Publish bot project
        run: dotnet publish src/TeamsBot/TeamsBot.csproj -c Release -o ./bot-output

      - name: Create Teams app package
        run: |
          cd src/TeamsBot/AppManifest
          jq '.version = "${{ github.run_number }}.0.0"' manifest.json > manifest.tmp
          mv manifest.tmp manifest.json
          zip -r ${{ github.workspace }}/teams-package.zip .

      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: deployment-package
          path: |
            ./bot-output
            ./teams-package.zip

  deploy:
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Download artifacts
        uses: actions/download-artifact@v4
        with:
          name: deployment-package

      - name: Azure Login (OIDC)
        uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

      - name: Deploy bot to App Service
        uses: azure/webapps-deploy@v3
        with:
          app-name: ${{ vars.BOT_APP_SERVICE_NAME }}
          package: ./bot-output

      - name: Setup CLI for Microsoft 365
        run: npm install -g @pnp/cli-microsoft365

      - name: Deploy Teams app to tenant catalog
        run: |
          m365 login --authType identity \
                      --appId ${{ vars.AZURE_CLIENT_ID }} \
                      --tenant ${{ vars.AZURE_TENANT_ID }}

          APP_EXISTS=$(m365 teams app list --query "[?externalId=='${{ vars.TEAMS_APP_EXTERNAL_ID }}'].id" -o text)

          if [ -n "$APP_EXISTS" ]; then
            echo "Updating existing app: $APP_EXISTS"
            m365 teams app update --id "$APP_EXISTS" --filePath ./teams-package.zip
          else
            echo "Publishing new app to catalog"
            m365 teams app publish --filePath ./teams-package.zip
          fi

That’s it. One file. Build, test, package, deploy, and publish to the Teams catalog all in one place.

Key differences from the Azure DevOps version

First off, the O365 CLI I used in the original post is long gone. It’s now called CLI for Microsoft 365 and has been for a while. The v9+ release is excellent and the community around it, led by Waldek Mastykarz and the PnP crew, keeps pushing it forward. The m365 teams app publish and m365 teams app update commands are exactly what you need for this kind of pipeline work. I already covered this in my Graph CLI retirement post.

The classic release pipeline with its drag-and-drop stages is replaced by GitHub Environments. You define a production environment in your repo settings, add protection rules like required reviewers, and reference it in your workflow with environment: production. It’s less visual but it works.

The biggest win is authentication. In my original Azure DevOps pipeline, I stored secrets in pipeline variables and library groups. Now I’m using Workload Identity Federation with OIDC. Notice the permissions: id-token: write at the top and the azure/login@v2 step that uses client-id instead of a secret. No passwords, no client secrets, no certificates to rotate. The GitHub runner requests a short-lived token, Azure validates it, done. If you haven’t set up Workload Identity Federation yet, I wrote about that in my previous post.

Now, one small thing I’m quite happy about: the jq trick for version stamping. In Azure DevOps I was manually updating the manifest version or not updating it at all (I’ll admit it). Here I use jq to inject the GitHub run number into the manifest’s version field before zipping. This way every build gets a unique version number automatically. Small thing, but it solves a problem I’d been ignoring for years.

What Azure DevOps still does better

I want to be honest here because I don’t think it’s helpful to pretend everything is better on the GitHub side. Classic release pipelines with approval gates are still more intuitive in Azure DevOps. You can see the stages, drag them around, set pre and post-deployment conditions with a few clicks. GitHub Environments with protection rules work, but the experience is more bare-bones.

Azure Boards integration is another one. If your team lives in Azure Boards for work item tracking, keeping your pipelines in Azure DevOps gives you that seamless link between commits, builds, and work items. GitHub Issues and Projects have improved a lot, but they’re not Azure Boards.

And audit trails. Azure DevOps has better built-in audit logging for pipeline runs, approvals, and deployments. For organizations with strict compliance requirements, that still matters.

But for new Teams apps? GitHub Actions is the way to go. The tooling is moving there, the community is building there, and the M365 Agents Toolkit generates GitHub Actions workflows out of the box.

The pipeline definition now lives right there in the repo alongside the code. When you clone the repo, you get the CI/CD for free. That alone makes it worth the move.