Publish SDK packages #89
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Publish SDK packages | |
| env: | |
| HUSKY: 0 | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| dist-tag: | |
| description: "Tag to publish under" | |
| type: choice | |
| required: true | |
| default: "prerelease" | |
| options: | |
| - latest | |
| - prerelease | |
| - unstable | |
| version: | |
| description: "Version override (optional, e.g., 1.0.0). If empty, auto-increments." | |
| type: string | |
| required: false | |
| internal-only: | |
| description: "Test only: publish the Node.js SDK exclusively to the internal feed" | |
| type: boolean | |
| required: false | |
| default: false | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: publish | |
| cancel-in-progress: false | |
| jobs: | |
| # Shared job to calculate version once for all publish jobs | |
| version: | |
| name: Calculate Version | |
| runs-on: ubuntu-latest | |
| outputs: | |
| version: ${{ steps.version.outputs.VERSION }} | |
| current: ${{ steps.version.outputs.CURRENT }} | |
| current-prerelease: ${{ steps.version.outputs.CURRENT_PRERELEASE }} | |
| defaults: | |
| run: | |
| working-directory: ./nodejs | |
| steps: | |
| - uses: actions/checkout@v6.0.2 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: "22.x" | |
| - run: npm ci --ignore-scripts | |
| - name: Get version | |
| id: version | |
| env: | |
| INTERNAL_ONLY: ${{ github.event.inputs.internal-only }} | |
| run: | | |
| CURRENT="$(node scripts/get-version.js current)" | |
| echo "CURRENT=$CURRENT" >> $GITHUB_OUTPUT | |
| echo "Current latest version: $CURRENT" >> $GITHUB_STEP_SUMMARY | |
| CURRENT_PRERELEASE="$(node scripts/get-version.js current-prerelease)" | |
| echo "CURRENT_PRERELEASE=$CURRENT_PRERELEASE" >> $GITHUB_OUTPUT | |
| echo "Current prerelease version: $CURRENT_PRERELEASE" >> $GITHUB_STEP_SUMMARY | |
| if [ "$INTERNAL_ONLY" = "true" ]; then | |
| VERSION="0.0.0-${GITHUB_RUN_ID}" | |
| echo "Using internal-only test version: $VERSION" >> $GITHUB_STEP_SUMMARY | |
| elif [ -n "${{ github.event.inputs.version }}" ]; then | |
| VERSION="${{ github.event.inputs.version }}" | |
| # Validate version format matches dist-tag | |
| if [ "${{ github.event.inputs.dist-tag }}" = "latest" ]; then | |
| if [[ "$VERSION" == *-* ]]; then | |
| echo "❌ Error: Version '$VERSION' has a prerelease suffix but dist-tag is 'latest'" >> $GITHUB_STEP_SUMMARY | |
| echo "Use a version without suffix (e.g., '1.0.0') for latest releases" | |
| exit 1 | |
| fi | |
| else | |
| if [[ "$VERSION" != *-* ]]; then | |
| echo "❌ Error: Version '$VERSION' has no prerelease suffix but dist-tag is '${{ github.event.inputs.dist-tag }}'" >> $GITHUB_STEP_SUMMARY | |
| echo "Use a version with suffix (e.g., '1.0.0-preview.0') for prerelease/unstable" | |
| exit 1 | |
| fi | |
| fi | |
| echo "Using manual version override: $VERSION" >> $GITHUB_STEP_SUMMARY | |
| else | |
| VERSION="$(node scripts/get-version.js ${{ github.event.inputs.dist-tag }})" | |
| echo "Auto-incremented version: $VERSION" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| echo "VERSION=$VERSION" >> $GITHUB_OUTPUT | |
| - name: Verify version is available on public npm | |
| env: | |
| VERSION: ${{ steps.version.outputs.VERSION }} | |
| run: | | |
| node scripts/npm-release.js preflight \ | |
| @github/copilot-sdk \ | |
| "$VERSION" \ | |
| https://registry.npmjs.org | |
| - name: Summarize internal-only validation | |
| if: github.event.inputs.internal-only == 'true' | |
| env: | |
| FEED_URL: https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/ | |
| VERSION: ${{ steps.version.outputs.VERSION }} | |
| run: | | |
| { | |
| echo "## Internal-only SDK publish validation" | |
| echo "" | |
| echo "| | |" | |
| echo "| --- | --- |" | |
| echo "| Mode | \`internal-only\` |" | |
| echo "| SDK version | \`${VERSION}\` |" | |
| echo "| Dist-tag | \`internal-test\` |" | |
| echo "| Feed | ${FEED_URL} |" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| package-nodejs: | |
| name: Package Node.js SDK | |
| needs: version | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| defaults: | |
| run: | |
| working-directory: ./nodejs | |
| steps: | |
| - uses: actions/checkout@v6.0.2 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: "22.x" | |
| - run: npm ci --ignore-scripts | |
| - name: Set version | |
| run: node scripts/set-version.js | |
| env: | |
| VERSION: ${{ needs.version.outputs.version }} | |
| - name: Build | |
| run: npm run build | |
| - name: Pack | |
| id: pack | |
| run: | | |
| TARBALL="$(npm pack . --json | jq -r '.[0].filename')" | |
| if [ -z "$TARBALL" ] || [ ! -f "$TARBALL" ]; then | |
| echo "::error::npm pack did not produce a tarball." | |
| exit 1 | |
| fi | |
| echo "tarball=$TARBALL" >> "$GITHUB_OUTPUT" | |
| - name: Record prepared package checksum | |
| if: github.event.inputs.internal-only == 'true' | |
| env: | |
| TARBALL: ${{ steps.pack.outputs.tarball }} | |
| run: sha512sum "$TARBALL" | |
| - name: Upload artifact | |
| uses: actions/upload-artifact@v7.0.0 | |
| with: | |
| name: nodejs-package | |
| path: nodejs/${{ steps.pack.outputs.tarball }} | |
| if-no-files-found: error | |
| publish-nodejs: | |
| name: Publish Node.js SDK | |
| needs: package-nodejs | |
| if: | | |
| github.event.inputs.internal-only != 'true' && | |
| (github.ref == 'refs/heads/main' || github.event.inputs.dist-tag == 'unstable') | |
| runs-on: ubuntu-latest | |
| permissions: | |
| actions: read | |
| contents: read | |
| id-token: write | |
| steps: | |
| - uses: actions/checkout@v6.0.2 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: "22.x" | |
| - name: Update npm for OIDC support | |
| run: npm i -g "npm@11.6.3" | |
| - name: Download Node.js package | |
| uses: actions/download-artifact@v8.0.0 | |
| with: | |
| name: nodejs-package | |
| path: ./dist | |
| - name: Publish tarball to public npm | |
| env: | |
| DIST_TAG: ${{ github.event.inputs.dist-tag }} | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob | |
| TARBALLS=(./dist/*.tgz) | |
| if [ "${#TARBALLS[@]}" -ne 1 ]; then | |
| echo "::error::Expected exactly one Node.js package tarball, found ${#TARBALLS[@]}." | |
| exit 1 | |
| fi | |
| node nodejs/scripts/npm-release.js publish \ | |
| "${TARBALLS[0]}" \ | |
| "$DIST_TAG" \ | |
| https://registry.npmjs.org \ | |
| public | |
| publish-nodejs-internal: | |
| name: Publish Node.js SDK to internal feed | |
| needs: [package-nodejs, publish-nodejs] | |
| if: | | |
| always() && | |
| needs.package-nodejs.result == 'success' && | |
| ( | |
| (github.event.inputs.internal-only == 'true' && needs.publish-nodejs.result == 'skipped') || | |
| (github.event.inputs.internal-only != 'true' && needs.publish-nodejs.result == 'success') | |
| ) | |
| environment: cicd | |
| runs-on: ubuntu-latest | |
| permissions: | |
| actions: read | |
| contents: read | |
| id-token: write | |
| env: | |
| ADO_RESOURCE: 499b84ac-1321-427f-aa17-267ca6975798 | |
| FEED_URL: https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/ | |
| steps: | |
| - uses: actions/checkout@v6.0.2 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: "22.x" | |
| - name: Download Node.js package | |
| uses: actions/download-artifact@v8.0.0 | |
| with: | |
| name: nodejs-package | |
| path: ./dist | |
| - name: Azure Login (OIDC -> id-cpd-ci) | |
| uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 | |
| with: | |
| client-id: "${{ vars.CPD_ID_CLIENT_ID }}" # id-cpd-ci | |
| tenant-id: "${{ vars.CPD_ID_TENANT_ID }}" | |
| allow-no-subscriptions: true | |
| - name: Configure feed auth | |
| run: | | |
| set -euo pipefail | |
| TOKEN="$(az account get-access-token --resource "$ADO_RESOURCE" --query accessToken -o tsv)" | |
| echo "::add-mask::$TOKEN" | |
| FEED_AUTH_REGISTRY="${FEED_URL#https:}" | |
| FEED_AUTH_BASE="${FEED_AUTH_REGISTRY%registry/}" | |
| printf '%s\n' \ | |
| "${FEED_AUTH_REGISTRY}:_authToken=${TOKEN}" \ | |
| "${FEED_AUTH_BASE}:_authToken=${TOKEN}" > "$HOME/.npmrc" | |
| - name: Record downloaded package checksum | |
| if: github.event.inputs.internal-only == 'true' | |
| run: sha512sum ./dist/*.tgz | |
| - name: Publish tarball to internal feed | |
| env: | |
| DIST_TAG: ${{ github.event.inputs.internal-only == 'true' && 'internal-test' || github.event.inputs.dist-tag }} | |
| run: | | |
| set -euo pipefail | |
| if [ "$FEED_URL" != "https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/" ]; then | |
| echo "::error::FEED_URL ('$FEED_URL') is not the expected internal feed. Refusing to publish." | |
| exit 1 | |
| fi | |
| shopt -s nullglob | |
| TARBALLS=(./dist/*.tgz) | |
| if [ "${#TARBALLS[@]}" -ne 1 ]; then | |
| echo "::error::Expected exactly one Node.js package tarball, found ${#TARBALLS[@]}." | |
| exit 1 | |
| fi | |
| node nodejs/scripts/npm-release.js publish \ | |
| "${TARBALLS[0]}" \ | |
| "$DIST_TAG" \ | |
| "$FEED_URL" \ | |
| azure | |
| - name: Intentionally fail first internal-only attempt | |
| if: github.event.inputs.internal-only == 'true' && github.run_attempt == 1 | |
| env: | |
| RUN_ID: ${{ github.run_id }} | |
| run: | | |
| { | |
| echo "## Intentional first-attempt failure" | |
| echo "" | |
| echo "The SDK was published to the internal feed. Re-run only failed jobs to exercise immutable Azure conflict handling:" | |
| echo "" | |
| echo "\`gh run rerun ${RUN_ID} --failed --repo ${GITHUB_REPOSITORY}\`" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| echo "::error::Intentional internal-only failure after the internal publish side effect." | |
| exit 1 | |
| publish-dotnet: | |
| name: Publish .NET SDK | |
| if: github.event.inputs.internal-only != 'true' && github.event.inputs.dist-tag != 'unstable' | |
| needs: version | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| id-token: write | |
| defaults: | |
| run: | |
| working-directory: ./dotnet | |
| steps: | |
| - uses: actions/checkout@v6.0.2 | |
| - uses: actions/setup-dotnet@v5 | |
| with: | |
| dotnet-version: "10.0.x" | |
| - name: Restore dependencies | |
| run: dotnet restore | |
| - name: Build and pack | |
| run: dotnet pack src/GitHub.Copilot.SDK.csproj -c Release -p:Version=${{ needs.version.outputs.version }} -o ./artifacts | |
| - name: Upload artifact | |
| uses: actions/upload-artifact@v7.0.0 | |
| with: | |
| name: dotnet-package | |
| path: | | |
| dotnet/artifacts/*.nupkg | |
| dotnet/artifacts/*.snupkg | |
| - name: NuGet login (OIDC) | |
| if: github.ref == 'refs/heads/main' | |
| uses: NuGet/login@v1 | |
| id: nuget-login | |
| with: | |
| # The following must be a username, not an organization name, and that user must have configured Trusted Publishing | |
| # for this owner/repo/workflow combination in their NuGet.org account settings. We could set up a dedicated user for | |
| # this purpose if needed, but then we'd have to manage that account separately. Other GitHub-owned packages on NuGet | |
| # are associated with individual maintainers' accounts too. | |
| user: stevesanderson | |
| - name: Publish to NuGet | |
| if: github.ref == 'refs/heads/main' | |
| run: | | |
| dotnet nuget push ./artifacts/*.nupkg --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate --no-symbols | |
| dotnet nuget push ./artifacts/*.snupkg --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate | |
| publish-rust: | |
| name: Publish Rust SDK | |
| if: github.event.inputs.internal-only != 'true' && github.event.inputs.dist-tag != 'unstable' | |
| needs: version | |
| runs-on: ubuntu-latest | |
| defaults: | |
| run: | |
| working-directory: ./rust | |
| steps: | |
| - uses: actions/checkout@v6.0.2 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| toolchain: "1.94.0" | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| workspaces: "rust" | |
| - name: Set version | |
| run: sed -i -E 's/^version = ".*"$/version = "${{ needs.version.outputs.version }}"/' Cargo.toml | |
| - name: Snapshot CLI version + hashes for build.rs | |
| run: | | |
| bash scripts/snapshot-bundled-cli-version.sh | |
| bash scripts/snapshot-bundled-in-process-version.sh | |
| - name: Verify CLI version snapshots exist | |
| run: | | |
| for snapshot in cli-version.txt cli-version-in-process.txt; do | |
| if [[ ! -f "${snapshot}" ]]; then | |
| echo "::error::${snapshot} was not generated. The Snapshot step must run before packaging." | |
| exit 1 | |
| fi | |
| done | |
| - name: Package (dry run) | |
| run: cargo publish --dry-run --allow-dirty | |
| - name: Upload artifact | |
| uses: actions/upload-artifact@v7.0.0 | |
| with: | |
| name: rust-package | |
| path: rust/target/package/*.crate | |
| - name: Publish to crates.io | |
| if: github.ref == 'refs/heads/main' | |
| run: cargo publish --allow-dirty | |
| env: | |
| CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} | |
| publish-python: | |
| name: Publish Python SDK | |
| if: github.event.inputs.internal-only != 'true' && github.event.inputs.dist-tag != 'unstable' | |
| needs: version | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| id-token: write | |
| defaults: | |
| run: | |
| working-directory: ./python | |
| steps: | |
| - uses: actions/checkout@v6.0.2 | |
| - uses: actions/setup-python@v6 | |
| with: | |
| python-version: "3.12" | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: "22.x" | |
| - name: Set up uv | |
| uses: astral-sh/setup-uv@v7 | |
| - name: Install Node.js dependencies (for CLI version) | |
| working-directory: ./nodejs | |
| run: npm ci --ignore-scripts | |
| - name: Set version | |
| run: sed -i "s/^version = .*/version = \"${{ needs.version.outputs.version }}\"/" pyproject.toml | |
| - name: Inject CLI version | |
| run: node scripts/inject-cli-version.mjs | |
| - name: Build wheel | |
| run: uv build --wheel --out-dir dist | |
| - name: Upload artifact | |
| uses: actions/upload-artifact@v7.0.0 | |
| with: | |
| name: python-package | |
| path: python/dist/* | |
| - name: Publish to PyPI | |
| if: github.ref == 'refs/heads/main' | |
| uses: pypa/gh-action-pypi-publish@release/v1 | |
| with: | |
| packages-dir: python/dist/ | |
| publish-java: | |
| name: Publish Java SDK | |
| if: github.event.inputs.internal-only != 'true' && github.event.inputs.dist-tag != 'unstable' && github.ref == 'refs/heads/main' | |
| needs: version | |
| permissions: | |
| contents: write | |
| id-token: write | |
| uses: ./.github/workflows/java-publish-maven.yml | |
| with: | |
| releaseVersion: ${{ needs.version.outputs.version }} | |
| prerelease: ${{ github.event.inputs.dist-tag == 'prerelease' }} | |
| secrets: inherit | |
| github-release: | |
| name: Create GitHub Release | |
| needs: | |
| [ | |
| version, | |
| publish-nodejs, | |
| publish-dotnet, | |
| publish-python, | |
| publish-rust, | |
| publish-java, | |
| ] | |
| if: | | |
| always() && | |
| github.event.inputs.internal-only != 'true' && | |
| github.ref == 'refs/heads/main' && | |
| github.event.inputs.dist-tag != 'unstable' && | |
| needs.version.result == 'success' && | |
| needs.publish-nodejs.result == 'success' && | |
| needs.publish-dotnet.result == 'success' && | |
| needs.publish-python.result == 'success' && | |
| needs.publish-rust.result == 'success' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| actions: write | |
| contents: write | |
| steps: | |
| - uses: actions/checkout@v6.0.2 | |
| - name: Create GitHub Release | |
| if: github.event.inputs.dist-tag == 'latest' | |
| run: | | |
| NOTES_FLAG="" | |
| if git rev-parse "v${{ needs.version.outputs.current }}" >/dev/null 2>&1; then | |
| NOTES_FLAG="--notes-start-tag v${{ needs.version.outputs.current }}" | |
| fi | |
| gh release create "v${{ needs.version.outputs.version }}" \ | |
| --title "v${{ needs.version.outputs.version }}" \ | |
| --generate-notes $NOTES_FLAG \ | |
| --target ${{ github.sha }} | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Create GitHub Pre-Release | |
| if: github.event.inputs.dist-tag == 'prerelease' | |
| run: | | |
| NOTES_FLAG="" | |
| if git rev-parse "v${{ needs.version.outputs.current-prerelease }}" >/dev/null 2>&1; then | |
| NOTES_FLAG="--notes-start-tag v${{ needs.version.outputs.current-prerelease }}" | |
| fi | |
| gh release create "v${{ needs.version.outputs.version }}" \ | |
| --prerelease \ | |
| --title "v${{ needs.version.outputs.version }}" \ | |
| --generate-notes $NOTES_FLAG \ | |
| --target ${{ github.sha }} | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Trigger changelog generation | |
| run: gh workflow run release-changelog.lock.yml -f tag="v${{ needs.version.outputs.version }}" | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Tag Go SDK submodule | |
| if: github.event.inputs.dist-tag == 'latest' || github.event.inputs.dist-tag == 'prerelease' | |
| run: | | |
| set -e | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git fetch --tags | |
| TAG_NAME="go/v${{ needs.version.outputs.version }}" | |
| # Try to create the tag - will fail if it already exists | |
| if git tag "$TAG_NAME" ${{ github.sha }} 2>/dev/null; then | |
| git push https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git "$TAG_NAME" | |
| echo "Created and pushed tag $TAG_NAME" | |
| else | |
| echo "Tag $TAG_NAME already exists, skipping" | |
| fi | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Tag Rust SDK and create Rust GitHub Release | |
| # Rust gets its own version-scoped GitHub Release with notes | |
| # derived from PR titles since the previous Rust tag. The | |
| # cross-language `vX.Y.Z` release above still exists; this one | |
| # is the canonical reference for Rust users. | |
| if: github.event.inputs.dist-tag == 'latest' || github.event.inputs.dist-tag == 'prerelease' | |
| run: | | |
| set -e | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git fetch --tags | |
| VERSION="${{ needs.version.outputs.version }}" | |
| TAG_NAME="rust/v${VERSION}" | |
| if git tag "$TAG_NAME" ${{ github.sha }} 2>/dev/null; then | |
| git push https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git "$TAG_NAME" | |
| echo "Created and pushed tag $TAG_NAME" | |
| else | |
| echo "Tag $TAG_NAME already exists, skipping tag push" | |
| fi | |
| # Find the previous Rust tag for note generation. Prefer rust/v*, | |
| # fall back to the historical rust-v* tags from the release-plz era. | |
| PREV_TAG=$(git tag --list 'rust/v*' --sort=-v:refname | grep -vFx "$TAG_NAME" | head -n1) | |
| if [ -z "$PREV_TAG" ]; then | |
| PREV_TAG=$(git tag --list 'rust-v*' --sort=-v:refname | head -n1) | |
| fi | |
| NOTES_FLAG="" | |
| if [ -n "$PREV_TAG" ]; then | |
| NOTES_FLAG="--notes-start-tag $PREV_TAG" | |
| echo "Generating notes from $PREV_TAG..$TAG_NAME" | |
| else | |
| echo "No previous Rust tag found; generating notes from full history" | |
| fi | |
| PRERELEASE_FLAG="" | |
| if [ "${{ github.event.inputs.dist-tag }}" = "prerelease" ]; then | |
| PRERELEASE_FLAG="--prerelease" | |
| fi | |
| gh release create "$TAG_NAME" \ | |
| --title "$TAG_NAME" \ | |
| --generate-notes $NOTES_FLAG $PRERELEASE_FLAG \ | |
| --target ${{ github.sha }} | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |