github-actionsci-cdpythonnpmnugetazure-artifactsdevopssdk

Un botón para publicar en Python, npm, NuGet y Databricks al mismo tiempo

Cuando tienes una plataforma de observabilidad con SDKs en Python, TypeScript, C# y Databricks, el release de una nueva versión implica coordinar cuatro publicaciones en cuatro feeds distintos. Si haces eso manualmente, eventualmente publicas tres de cuatro y el cuarto se te olvida.

Este post es el workflow de GitHub Actions que automatiza ese proceso: un solo trigger de release, cuatro publicaciones en paralelo, gates de calidad previos, idempotencia para re-runs seguros.

La estructura del repositorio

sdk-monorepo/
├── .github/
│   └── workflows/
│       ├── ci.yml          # Test en cada PR
│       └── release.yml     # Publicar al crear release
├── sdk-python/
│   ├── src/
│   ├── tests/
│   └── pyproject.toml
├── sdk-typescript/
│   ├── src/
│   ├── tests/
│   └── package.json
├── sdk-csharp/
│   ├── src/
│   ├── tests/
│   └── *.csproj
└── sdk-databricks/
    ├── src/
    └── pyproject.toml

Un repositorio, cuatro SDKs, un workflow de release. El monorepo garantiza que cuando publicas, publicas todos.

El trigger: GitHub Release

El workflow se activa al crear un release en GitHub, no al hacer push. Esto da control explícito sobre cuándo se publica:

name: SDK Release

on:
  release:
    types: [published]

env:
  PYTHON_FEED: https://pkgs.dev.azure.com/org/project/_packaging/feed/pypi/simple/
  NPM_REGISTRY: https://pkgs.dev.azure.com/org/project/_packaging/feed/npm/registry/
  NUGET_SOURCE: https://pkgs.dev.azure.com/org/project/_packaging/feed/nuget/v3/index.json

El release en GitHub tiene un tag (v1.4.0) que es la fuente de verdad de la versión. Los pyproject.toml y package.json deben estar sincronizados con ese tag antes de crear el release.

Job 1: Quality gates

Antes de publicar, verificar que todos los tests pasan en todos los lenguajes:

jobs:
  quality-gates:
    name: Quality gates — ${{ matrix.sdk }}
    runs-on: ubuntu-latest
    strategy:
      fail-fast: true
      matrix:
        sdk: [python, typescript, csharp, databricks]
        include:
          - sdk: python
            dir: sdk-python
            test-cmd: pytest tests/ -v --tb=short
          - sdk: typescript
            dir: sdk-typescript
            test-cmd: npm test
          - sdk: csharp
            dir: sdk-csharp
            test-cmd: dotnet test --configuration Release
          - sdk: databricks
            dir: sdk-databricks
            test-cmd: pytest tests/ -v

    steps:
      - uses: actions/checkout@v4

      - name: Setup Python
        if: matrix.sdk == 'python' || matrix.sdk == 'databricks'
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Setup Node
        if: matrix.sdk == 'typescript'
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Setup .NET
        if: matrix.sdk == 'csharp'
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.x'

      - name: Install dependencies
        run: |
          cd ${{ matrix.dir }}
          case "${{ matrix.sdk }}" in
            python|databricks) pip install -e ".[dev]" ;;
            typescript) npm ci ;;
            csharp) dotnet restore ;;
          esac

      - name: Run tests
        run: |
          cd ${{ matrix.dir }}
          ${{ matrix.test-cmd }}

fail-fast: true detiene todo si cualquier SDK falla los tests. No tiene sentido publicar tres SDKs si uno no pasa quality gates.

Job 2: Publish en paralelo

Después de que todos los quality gates pasan, publicar los cuatro SDKs en paralelo:

  publish:
    name: Publish — ${{ matrix.sdk }}
    needs: quality-gates
    runs-on: ubuntu-latest
    permissions:
      id-token: write   # Para WIF con Azure
      contents: read
    strategy:
      fail-fast: false  # Si uno falla, continuar con los otros
      matrix:
        sdk: [python, typescript, csharp, databricks]

    steps:
      - uses: actions/checkout@v4

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

      - name: Get Azure Artifacts token
        id: artifacts-token
        run: |
          TOKEN=$(az account get-access-token \
            --resource https://pkgs.dev.azure.com \
            --query accessToken -o tsv)
          echo "::add-mask::$TOKEN"
          echo "token=$TOKEN" >> $GITHUB_OUTPUT

fail-fast: false en el publish job permite que si Python falla (por ejemplo, por un error de red), TypeScript y C# sigan publicando. Los errores se reportan individualmente.

Publicación Python (con skip idempotente)

      - name: Publish Python SDK
        if: matrix.sdk == 'python'
        env:
          TWINE_USERNAME: __token__
          TWINE_PASSWORD: ${{ steps.artifacts-token.outputs.token }}
        run: |
          cd sdk-python
          pip install build twine

          VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
          echo "Publishing Python SDK version: $VERSION"

          # Skip if already published (idempotencia)
          if pip index versions mi-org-observability \
              --index-url ${{ env.PYTHON_FEED }} 2>/dev/null | grep -q "$VERSION"; then
            echo "Version $VERSION already published, skipping"
            exit 0
          fi

          python -m build --wheel
          twine upload --repository-url ${{ env.PYTHON_FEED }} dist/*.whl

Publicación TypeScript (con .npmrc dinámico)

El feed de Azure Artifacts para npm requiere autenticación en .npmrc. Generarlo dinámicamente en el step evita guardar el token en el repositorio:

      - name: Publish TypeScript SDK
        if: matrix.sdk == 'typescript'
        run: |
          cd sdk-typescript

          # .npmrc con token dinámico
          FEED_DOMAIN="pkgs.dev.azure.com"
          TOKEN="${{ steps.artifacts-token.outputs.token }}"
          B64_TOKEN=$(echo -n "PAT:$TOKEN" | base64)

          cat > .npmrc << EOF
          registry=${{ env.NPM_REGISTRY }}
          always-auth=true
          //${FEED_DOMAIN}/:_authToken=${TOKEN}
          EOF

          VERSION=$(node -e "console.log(require('./package.json').version)")
          echo "Publishing TypeScript SDK version: $VERSION"

          # Skip if already published
          if npm view mi-org-observability@$VERSION --registry ${{ env.NPM_REGISTRY }} 2>/dev/null; then
            echo "Version $VERSION already published, skipping"
            exit 0
          fi

          npm ci
          npm run build
          npm publish --registry ${{ env.NPM_REGISTRY }}

Publicación C# con NuGet

      - name: Publish C# SDK
        if: matrix.sdk == 'csharp'
        run: |
          cd sdk-csharp

          # Registrar feed con token
          dotnet nuget add source ${{ env.NUGET_SOURCE }} \
            --name AzureArtifacts \
            --username PAT \
            --password "${{ steps.artifacts-token.outputs.token }}" \
            --store-password-in-clear-text

          VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/**/*.csproj | head -1)
          echo "Publishing C# SDK version: $VERSION"

          dotnet build --configuration Release
          dotnet pack --configuration Release --no-build

          # --skip-duplicate hace el publish idempotente
          dotnet nuget push "**/*.nupkg" \
            --source AzureArtifacts \
            --skip-duplicate

Job 3: Release summary

Después de todas las publicaciones, un job final agrega un comentario al release con el resultado:

  release-summary:
    name: Release summary
    needs: publish
    runs-on: ubuntu-latest
    if: always()
    permissions:
      contents: write

    steps:
      - name: Update release notes
        uses: softprops/action-gh-release@v2
        with:
          body: |
            ## Published packages

            | SDK | Feed | Status |
            |-----|------|--------|
            | Python | Azure Artifacts PyPI | ${{ needs.publish.result == 'success' && '✓' || '✗' }} |
            | TypeScript | Azure Artifacts npm | ${{ needs.publish.result == 'success' && '✓' || '✗' }} |
            | C# | Azure Artifacts NuGet | ${{ needs.publish.result == 'success' && '✓' || '✗' }} |
            | Databricks | Wheel storage | ${{ needs.publish.result == 'success' && '✓' || '✗' }} |

            Released: ${{ github.ref_name }}
          append_body: true

El if: always() garantiza que el summary se ejecuta aunque algún publish falle. append_body: true agrega al body del release sin sobreescribir las notas manuales.

Lo que aprendí

Matrix strategy en publish: fail-fast: false. En quality gates quieres fallar rápido. En publish, quieres publicar lo que puedas y reportar errores por separado. Un error de red en npm no debe cancelar el publish de NuGet.

Los tokens de Azure Artifacts son efímeros por diseño. WIF genera tokens de corta vida. Generarlos en el step (no en env global) garantiza que están frescos cuando se usan.

Un release en GitHub es el mecanismo de coordinación correcto. Push a main puede triggear CI. El release tag es el trigger de publicación. Esa separación previene publicaciones accidentales desde branches.

El monorepo de SDKs es la única forma de garantizar consistencia. SDKs en repositorios separados significan workflows separados, versiones que se desincrónizan, y la posibilidad de hacer release de uno sin el otro.

Volver al blog